diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 0000000..2e623cd --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,37 @@ +# .agents + +Canonical shared runtime for Delano. + +## Source-of-truth map + +- Root agent workflow and boundaries: `AGENTS.md` +- Process and operator handbook: `HANDBOOK.md` +- Delivery contracts and evidence: `.project/` +- Runtime scripts: `.agents/scripts/` +- Shared rules: `.agents/rules/` +- Hooks: `.agents/hooks/` +- Skills: `.agents/skills/` +- Logs: `.agents/logs/` +- Shared adapter utilities: `.agents/common/` +- Agent-specific adapter notes: `.agents/adapters//` + +The compatibility path `.claude/` may mirror this runtime for agents that still expect Claude-style paths. + +## Required first-turn behavior + +Every adapter should start from `AGENTS.md`, inspect the relevant `.project` contract and current git state, then use the adapter note only for runtime-specific differences. + +## Core validation commands + +- `bash .agents/scripts/pm/validate.sh` +- `npm test` +- `npm run build:assets` +- `npm run check:package-manifest` + +## Completion rule + +Do not report a task as done until the change is present, evidence is recorded, validation has passed or been explicitly marked not run, and blockers are named. + +## Safety boundaries + +Keep logs privacy-safe, avoid absolute path leaks in docs/contracts/hook output, and do not perform destructive git or remote-write operations without an explicit task instruction. diff --git a/.agents/adapters/claude/README.md b/.agents/adapters/claude/README.md new file mode 100644 index 0000000..23fb7e2 --- /dev/null +++ b/.agents/adapters/claude/README.md @@ -0,0 +1,24 @@ +# Claude adapter + +## Start here + +1. Read root `AGENTS.md` first. +2. Use this note only for Claude-specific path behavior. +3. Inspect `git status --short --branch` and the assigned `.project` contract before editing. + +## Runtime paths + +- Canonical runtime: `.agents/` +- Claude compatibility path: `.claude/` mirrors `.agents/` where available +- Delivery contracts: `.project/` + +## Commands + +- `bash .agents/scripts/pm/validate.sh` +- `npm test` +- `npm run build:assets` +- `npm run check:package-manifest` + +## Completion and safety + +Record evidence in the task or update log before marking work done. Do not rely on `.claude/` as a separate source of truth, do not commit unsafe logs, and do not leak local absolute paths in output. diff --git a/.agents/adapters/codex/README.md b/.agents/adapters/codex/README.md new file mode 100644 index 0000000..1642023 --- /dev/null +++ b/.agents/adapters/codex/README.md @@ -0,0 +1,24 @@ +# Codex adapter + +## Start here + +1. Read root `AGENTS.md` first. +2. Use this note only for Codex-specific execution reminders. +3. Inspect `git status --short --branch` and the assigned `.project` contract before editing. + +## Runtime paths + +- Delivery contracts: `.project/` +- Canonical runtime scripts, hooks, rules, and skills: `.agents/` +- Compatibility path if needed: `.claude/` + +## Commands + +- `bash .agents/scripts/pm/validate.sh` +- `npm test` +- `npm run build:assets` +- `npm run check:package-manifest` + +## Completion and safety + +Keep changes task-scoped, record evidence before marking done, and report exactly which validation commands passed or were not run. Do not force-push, apply remote writes, commit unsafe logs, or expose local absolute paths unless an explicit task instruction requires it. diff --git a/.agents/adapters/manifest.schema.json b/.agents/adapters/manifest.schema.json new file mode 100644 index 0000000..3eb337e --- /dev/null +++ b/.agents/adapters/manifest.schema.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.dev/schemas/adapter-manifest.schema.json", + "title": "Delano Adapter Manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "type", + "owner", + "status", + "summary", + "commands", + "generated_files", + "validation", + "install", + "limits" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["agent", "authoring-tool", "sync-tool", "workflow"] + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": ["proposed", "experimental", "stable", "deprecated"] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "commands": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "description", "input", "output", "writes", "validation"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "input": { "type": "array", "items": { "type": "string" } }, + "output": { "type": "array", "items": { "type": "string" } }, + "writes": { "type": "array", "items": { "type": "string" } }, + "validation": { "type": "array", "items": { "type": "string" } } + } + } + }, + "generated_files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "owner", "mode", "conflict_behavior", "fold_forward"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "owner": { "type": "string", "minLength": 1 }, + "mode": { "type": "string", "enum": ["create-only", "update-owned", "proposal-only", "never-overwrite"] }, + "conflict_behavior": { "type": "string", "enum": ["abort", "diff-required", "operator-approval-required"] }, + "fold_forward": { "type": "string", "minLength": 1 } + } + } + }, + "validation": { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + }, + "install": { + "type": "object", + "additionalProperties": false, + "required": ["categories", "conflict_policy"], + "properties": { + "categories": { + "type": "array", + "items": { "type": "string" } + }, + "conflict_policy": { + "type": "string", + "minLength": 1 + } + } + }, + "limits": { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + } + } +} diff --git a/.agents/adapters/opencode/README.md b/.agents/adapters/opencode/README.md new file mode 100644 index 0000000..1501258 --- /dev/null +++ b/.agents/adapters/opencode/README.md @@ -0,0 +1,24 @@ +# OpenCode adapter + +## Start here + +1. Read root `AGENTS.md` first. +2. Use this note only for OpenCode-specific execution reminders. +3. Inspect `git status --short --branch` and the assigned `.project` contract before editing. + +## Runtime paths + +- Delivery contracts: `.project/` +- Canonical runtime scripts, hooks, rules, and skills: `.agents/` +- Compatibility path if needed: `.claude/` + +## Commands + +- `bash .agents/scripts/pm/validate.sh` +- `npm test` +- `npm run build:assets` +- `npm run check:package-manifest` + +## Completion and safety + +Keep changes task-scoped, record evidence before marking done, and report exactly which validation commands passed or were not run. Do not force-push, apply remote writes, commit unsafe logs, or expose local absolute paths unless an explicit task instruction requires it. diff --git a/.agents/adapters/pi/README.md b/.agents/adapters/pi/README.md new file mode 100644 index 0000000..33ee27e --- /dev/null +++ b/.agents/adapters/pi/README.md @@ -0,0 +1,24 @@ +# Pi adapter + +## Start here + +1. Read root `AGENTS.md` first. +2. Use this note only for Pi-specific execution reminders. +3. Inspect `git status --short --branch` and the assigned `.project` contract before editing. + +## Runtime paths + +- Delivery contracts: `.project/` +- Canonical runtime scripts, hooks, rules, and skills: `.agents/` +- Compatibility path if needed: `.claude/` + +## Commands + +- `bash .agents/scripts/pm/validate.sh` +- `npm test` +- `npm run build:assets` +- `npm run check:package-manifest` + +## Completion and safety + +Keep changes task-scoped, record evidence before marking done, and report exactly which validation commands passed or were not run. Do not force-push, apply remote writes, commit unsafe logs, or expose local absolute paths unless an explicit task instruction requires it. diff --git a/.agents/adapters/spec-kit/adapter.json b/.agents/adapters/spec-kit/adapter.json new file mode 100644 index 0000000..ee69e36 --- /dev/null +++ b/.agents/adapters/spec-kit/adapter.json @@ -0,0 +1,71 @@ +{ + "id": "spec-kit", + "name": "Spec Kit Interop", + "type": "authoring-tool", + "owner": "delano-team", + "status": "experimental", + "summary": "Imports Spec Kit-style intent artifacts into Delano-governed delivery projects.", + "commands": [ + { + "name": "delano import-spec-kit", + "description": "Create a planned Delano project from a supported Spec Kit-style markdown artifact.", + "input": ["slug", "source-md", "--name", "--owner", "--lead", "--json"], + "output": ["human summary", "JSON result with ok, command, project, source, validation"], + "writes": [".project/projects//"], + "validation": ["delano validate"] + }, + { + "name": "delano research", + "description": "Open repo-native research intake for unclear imported intent.", + "input": ["project-slug", "research-slug", "--title", "--question", "--json"], + "output": ["human summary", "JSON result with ok, command, project, research, files, validation"], + "writes": [".project/projects//research//"], + "validation": ["delano validate"] + } + ], + "generated_files": [ + { + "path": ".project/projects//spec.md", + "owner": "spec-kit adapter", + "mode": "create-only", + "conflict_behavior": "abort", + "fold_forward": "canonical spec" + }, + { + "path": ".project/projects//plan.md", + "owner": "spec-kit adapter", + "mode": "create-only", + "conflict_behavior": "abort", + "fold_forward": "canonical plan" + }, + { + "path": ".project/projects//tasks/*.md", + "owner": "spec-kit adapter", + "mode": "create-only", + "conflict_behavior": "abort", + "fold_forward": "canonical tasks with evidence gates" + }, + { + "path": ".project/projects//research//", + "owner": "research intake", + "mode": "create-only", + "conflict_behavior": "abort", + "fold_forward": "spec, plan, decisions, workstreams, tasks, or updates" + } + ], + "validation": [ + "delano validate", + "npm run check:text-safety for Delano repo changes", + "fixture import smoke before release" + ], + "install": { + "categories": ["agent-runtime", "project-templates", "skills"], + "conflict_policy": "Use existing Delano install allowlist behavior; abort on generated project collisions unless an operator approves a diff-backed change." + }, + "limits": [ + "Does not replace Spec Kit.", + "Does not execute imported tasks automatically.", + "Does not write Linear or GitHub state directly.", + "Does not depend on Obsidian, OpenClaw, or private local paths." + ] +} diff --git a/.agents/common/README.md b/.agents/common/README.md new file mode 100644 index 0000000..d100a04 --- /dev/null +++ b/.agents/common/README.md @@ -0,0 +1,3 @@ +# Common adapter guidance + +All adapters must operate against `.project/` contracts and follow `HANDBOOK.md` gates. diff --git a/.agents/common/log-safety.js b/.agents/common/log-safety.js new file mode 100644 index 0000000..0d09762 --- /dev/null +++ b/.agents/common/log-safety.js @@ -0,0 +1,55 @@ +const crypto = require('crypto'); + +const SECRET_PATTERNS = [ + /\b(sk-[A-Za-z0-9_-]{12,})\b/g, + /\b(github_pat_[A-Za-z0-9_]{20,})\b/g, + /\b(gh[pousr]_[A-Za-z0-9_]{20,})\b/g, + /\b(xox[baprs]-[A-Za-z0-9-]{10,})\b/g, + /\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g, + /\b(password|passwd|pwd|secret|token|api[_-]?key)\s*[:=]\s*([^\s"']+)/gi +]; + +function sha256Hex(value) { + return crypto.createHash('sha256').update(String(value || '')).digest('hex'); +} + +function envFlag(name, defaultValue = false) { + const value = process.env[name]; + if (value === undefined || value === '') return defaultValue; + return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); +} + +function redactString(value) { + let output = String(value || ''); + let replacements = 0; + + for (const pattern of SECRET_PATTERNS) { + output = output.replace(pattern, (...args) => { + replacements += 1; + const match = args[0]; + if (/^(password|passwd|pwd|secret|token|api[_-]?key)/i.test(match)) { + return match.replace(/[:=]\s*[^\s"']+/i, ': [REDACTED]'); + } + return '[REDACTED]'; + }); + } + + return { value: output, replacements }; +} + +function redactObject(value) { + if (typeof value === 'string') return redactString(value).value; + if (Array.isArray(value)) return value.map(redactObject); + if (!value || typeof value !== 'object') return value; + + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [key, redactObject(nested)]) + ); +} + +module.exports = { + envFlag, + redactObject, + redactString, + sha256Hex +}; diff --git a/.agents/eval-fixtures/skill-output/invalid/missing-evidence/output.json b/.agents/eval-fixtures/skill-output/invalid/missing-evidence/output.json new file mode 100644 index 0000000..977c5b6 --- /dev/null +++ b/.agents/eval-fixtures/skill-output/invalid/missing-evidence/output.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "skill": "learning-skill", + "outcome": "claims success without evidence", + "privacy": "metadata-only" +} diff --git a/.agents/eval-fixtures/skill-output/valid/summary/output.json b/.agents/eval-fixtures/skill-output/valid/summary/output.json new file mode 100644 index 0000000..c0b4dd5 --- /dev/null +++ b/.agents/eval-fixtures/skill-output/valid/summary/output.json @@ -0,0 +1,7 @@ +{ + "schema_version": 1, + "skill": "learning-skill", + "outcome": "summary-only delivery metrics recorded", + "evidence": ["scripts/summarize-project-metrics.mjs", ".project/registry/linear-map.json"], + "privacy": "metadata-only" +} diff --git a/.agents/fixtures/github/status-snapshot.json b/.agents/fixtures/github/status-snapshot.json new file mode 100644 index 0000000..7b31c0d --- /dev/null +++ b/.agents/fixtures/github/status-snapshot.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "source": "mock-github-status-snapshot", + "generated_at": "2026-04-30T00:55:00Z", + "repositories": [] +} diff --git a/.agents/fixtures/linear/issue-snapshot.json b/.agents/fixtures/linear/issue-snapshot.json new file mode 100644 index 0000000..f5a572a --- /dev/null +++ b/.agents/fixtures/linear/issue-snapshot.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "source": "mock-linear-issue-snapshot", + "generated_at": "2026-04-30T01:05:00Z", + "issues": [] +} diff --git a/.agents/hooks/README.md b/.agents/hooks/README.md new file mode 100644 index 0000000..7ab9be2 --- /dev/null +++ b/.agents/hooks/README.md @@ -0,0 +1,16 @@ +# Delano Hook Layer + +This directory contains optional runtime hooks for session and mutation tracking. + +Default hooks: +- `session-tracker.js` +- `post-tool-logger.js` +- `user-prompt-logger.js` +- `bash-worktree-fix.sh` +- `codex-session-status.js` + +The logging hooks append JSONL records in `.agents/logs/`. + +`codex-session-status.js` is used by the optional `.codex/hooks.json` SessionStart +configuration. It emits `delano status --open --brief` context and fails open if +the local runtime is not available. diff --git a/.agents/hooks/bash-worktree-fix.sh b/.agents/hooks/bash-worktree-fix.sh new file mode 100644 index 0000000..5022f16 --- /dev/null +++ b/.agents/hooks/bash-worktree-fix.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Ensure shell starts in repository root when invoked from nested worktree paths. +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" +repo_name="$(basename "$root")" +echo "worktree context set: ($repo_name)" diff --git a/.agents/hooks/codex-session-status.js b/.agents/hooks/codex-session-status.js new file mode 100644 index 0000000..996ab44 --- /dev/null +++ b/.agents/hooks/codex-session-status.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node +const { existsSync } = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +function findDelanoRoot(startDir) { + let current = path.resolve(startDir); + while (true) { + if ( + existsSync(path.join(current, ".project", "projects")) && + existsSync(path.join(current, ".agents", "scripts", "pm", "status.sh")) + ) { + return current; + } + + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function toBashPath(filePath) { + return filePath.replace(/\\/g, "/"); +} + +function resolveBash() { + const candidates = []; + + if (process.env.DELANO_BASH) { + candidates.push(process.env.DELANO_BASH); + } + + if (process.platform === "win32") { + candidates.push( + "C:\\Program Files\\Git\\bin\\bash.exe", + "C:\\Program Files\\Git\\usr\\bin\\bash.exe" + ); + + const whereResult = spawnSync("where.exe", ["bash"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }); + if (whereResult.status === 0 && whereResult.stdout) { + candidates.push(...whereResult.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)); + } + } else { + const whichResult = spawnSync("which", ["bash"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }); + if (whichResult.status === 0 && whichResult.stdout.trim()) { + candidates.push(whichResult.stdout.trim()); + } + candidates.push("/usr/bin/bash", "/bin/bash"); + } + + return candidates.find((candidate) => candidate && existsSync(candidate)) || null; +} + +const root = findDelanoRoot(process.cwd()); +const bashPath = resolveBash(); +if (!root || !bashPath) { + process.exit(0); +} + +const statusScript = toBashPath(path.join(root, ".agents", "scripts", "pm", "status.sh")); +const result = spawnSync(bashPath, [statusScript, "--open", "--brief"], { + cwd: root, + encoding: "utf8", + timeout: 4500, + maxBuffer: 64 * 1024 +}); + +if (result.error || result.status !== 0) { + process.exit(0); +} + +const statusOutput = result.stdout.trim(); +if (!statusOutput) { + process.exit(0); +} + +const additionalContext = formatStatusContext(statusOutput); + +console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext + } +})); + +function formatStatusContext(rawStatusOutput) { + const lines = rawStatusOutput.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const projectLines = lines.filter((line) => ( + !line.startsWith("Delano ") && + !/^=+$/.test(line) && + !line.startsWith("No open projects") + )); + + if (projectLines.length === 0) { + return "Delano startup context. Open projects: none."; + } + + const projects = projectLines.map(formatProjectLine); + return `Delano startup context. Open projects: ${projects.join("; ")}.`; +} + +function formatProjectLine(line) { + const match = line.match(/^(\S+)\s+spec=(\S+)\s+plan=(\S+)\s+open_tasks=(\d+)\s+total_tasks=(\d+)$/); + if (!match) { + return line; + } + + const [, slug, spec, plan, openTasks, totalTasks] = match; + return `${slug} (spec=${spec}, plan=${plan}, open_tasks=${openTasks}, total_tasks=${totalTasks})`; +} + +module.exports = { + formatProjectLine, + formatStatusContext +}; diff --git a/.agents/hooks/post-tool-logger.js b/.agents/hooks/post-tool-logger.js new file mode 100644 index 0000000..6e0eb2c --- /dev/null +++ b/.agents/hooks/post-tool-logger.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { redactObject } = require('../common/log-safety'); + +const payload = process.argv[2] ? JSON.parse(process.argv[2]) : { type: 'tool_mutation' }; +const root = process.cwd(); +const dir = path.join(root, '.agents', 'logs'); +const file = path.join(dir, 'changes.jsonl'); +fs.mkdirSync(dir, { recursive: true }); + +const row = { + timestamp: new Date().toISOString(), + type: payload.type || 'tool_mutation', + actor: payload.actor || 'runtime', + meta: redactObject(payload.meta || {}) +}; + +fs.appendFileSync(file, JSON.stringify(row) + '\n', 'utf8'); diff --git a/.agents/hooks/session-tracker.js b/.agents/hooks/session-tracker.js new file mode 100644 index 0000000..8ecf96b --- /dev/null +++ b/.agents/hooks/session-tracker.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); + +const [action = 'start', sessionId = 'unknown'] = process.argv.slice(2); +const root = process.cwd(); +const dir = path.join(root, '.agents', 'logs'); +const file = path.join(dir, 'sessions.jsonl'); +fs.mkdirSync(dir, { recursive: true }); + +const row = { + timestamp: new Date().toISOString(), + action, + sessionId +}; + +fs.appendFileSync(file, JSON.stringify(row) + '\n', 'utf8'); diff --git a/.agents/hooks/user-prompt-logger.js b/.agents/hooks/user-prompt-logger.js new file mode 100644 index 0000000..e8f5f08 --- /dev/null +++ b/.agents/hooks/user-prompt-logger.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { envFlag, redactString, sha256Hex } = require('../common/log-safety'); + +const prompt = process.argv.slice(2).join(' '); +if (!prompt) process.exit(0); + +const root = process.cwd(); +const dir = path.join(root, '.agents', 'logs'); +const file = path.join(dir, 'prompts.jsonl'); +fs.mkdirSync(dir, { recursive: true }); + +const redacted = redactString(prompt); +const row = { + timestamp: new Date().toISOString(), + prompt_hash: sha256Hex(prompt), + prompt_length: prompt.length, + redaction: { + applied: redacted.replacements > 0, + replacements: redacted.replacements + } +}; + +if (envFlag('DELANO_LOG_REDACTED_PROMPTS')) { + row.prompt_redacted = redacted.value; +} + +if (envFlag('DELANO_LOG_RAW_PROMPTS')) { + row.prompt_raw = envFlag('DELANO_LOG_UNREDACTED_PROMPTS') ? prompt : redacted.value; + row.raw_prompt_redacted = !envFlag('DELANO_LOG_UNREDACTED_PROMPTS'); +} + +fs.appendFileSync(file, JSON.stringify(row) + '\n', 'utf8'); diff --git a/.agents/logs/.gitkeep b/.agents/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.agents/logs/delivery-metrics.md b/.agents/logs/delivery-metrics.md new file mode 100644 index 0000000..1fc4567 --- /dev/null +++ b/.agents/logs/delivery-metrics.md @@ -0,0 +1,22 @@ +# Delivery Metrics + +Delivery metric events are local, metadata-only records used to summarize Delano delivery flow without copying prompt text, raw logs, customer data, or machine-local paths. + +## Location + +- Runtime stream: `.agents/logs/delivery-events.jsonl` +- Compatibility runtime: `.claude/logs/delivery-events.jsonl` +- Contract: `.agents/schemas/metrics/delivery-events.schema.json` + +## Captured events + +- `task_status_changed`: task lifecycle movement, especially ready/in-progress/done/blocked. +- `validation_run`: validation command result and count summary. +- `lease_acquired` / `lease_released`: multi-agent lease lifecycle. +- `drift_report_generated`: dry-run sync drift report summary. +- `repair_plan_created`: gated repair plan summary. +- `closeout_recorded`: closeout and learning-loop handoff. + +## Privacy posture + +Events are metadata-only summaries. Store repo-relative paths only, hash or omit sensitive values, and keep command evidence limited to commands that are safe to show in local project evidence. diff --git a/.agents/logs/schema.md b/.agents/logs/schema.md new file mode 100644 index 0000000..ebc5225 --- /dev/null +++ b/.agents/logs/schema.md @@ -0,0 +1,61 @@ +# Log Schema + +Delano logs are local runtime evidence. Treat them as operationally sensitive and do not copy them into public issues, PRs, or reports without review. + +## `changes.jsonl` + +Metadata is redacted before write by Delano logging helpers. + +```json +{ + "timestamp": "ISO8601 UTC", + "type": "event_type", + "actor": "system|agent|user", + "meta": {} +} +``` + +## `sessions.jsonl` + +```json +{ + "timestamp": "ISO8601 UTC", + "action": "start|end", + "sessionId": "string" +} +``` + +## `prompts.jsonl` + +Raw prompt text is not stored by default. Default prompt logs store only hash/length/redaction metadata. + +```json +{ + "timestamp": "ISO8601 UTC", + "prompt_hash": "sha256 hex string", + "prompt_length": 123, + "redaction": { + "applied": true, + "replacements": 1 + } +} +``` + +Optional environment flags: + +- `DELANO_LOG_REDACTED_PROMPTS=1`: also write `prompt_redacted`. +- `DELANO_LOG_RAW_PROMPTS=1`: also write `prompt_raw`, redacted unless explicitly overridden. +- `DELANO_LOG_UNREDACTED_PROMPTS=1`: allow unredacted `prompt_raw`; only use in private debugging contexts. + +## `test-runs.jsonl` + +Test logs intentionally preserve command output as evidence. Review logs before sharing outside the local repo. + +```json +{ + "timestamp": "ISO8601 UTC", + "command": "string", + "exit_code": 0, + "log_file": ".agents/logs/tests/.log" +} +``` diff --git a/.agents/rules/README.md b/.agents/rules/README.md new file mode 100644 index 0000000..13d1be1 --- /dev/null +++ b/.agents/rules/README.md @@ -0,0 +1,12 @@ +# Delano Rule Pack + +Rules encode the guardrails described in `HANDBOOK.md`. + +Core rule scopes: + +- datetime + frontmatter integrity +- GitHub safety checks +- path privacy +- branch/worktree safety +- test execution hygiene +- agent coordination protocol diff --git a/.agents/rules/agent-coordination.md b/.agents/rules/agent-coordination.md new file mode 100644 index 0000000..631bc5a --- /dev/null +++ b/.agents/rules/agent-coordination.md @@ -0,0 +1,5 @@ +# Agent Coordination Rule + +- Use multi-stream orchestration only after explicit threshold check. +- Declare stream scope and conflict zones before execution. +- Synchronize at dependency boundaries and escalate overlap early. diff --git a/.agents/rules/datetime.md b/.agents/rules/datetime.md new file mode 100644 index 0000000..5ffce9a --- /dev/null +++ b/.agents/rules/datetime.md @@ -0,0 +1,5 @@ +# Datetime Rule + +- Use UTC ISO8601 (`YYYY-MM-DDTHH:MM:SSZ`) for `created`, `updated`, and update timestamps. +- `created` is immutable after file creation. +- `updated` must reflect real mutation time. diff --git a/.agents/rules/delivery-modes.md b/.agents/rules/delivery-modes.md new file mode 100644 index 0000000..badb4ce --- /dev/null +++ b/.agents/rules/delivery-modes.md @@ -0,0 +1,17 @@ +# Delano operating modes + +Use operating modes to choose the lightest safe delivery contract. The mode should reduce ambiguity, not add ceremony. + +| Mode | Slug | Name | Best fit | Minimum contract | +| --- | --- | --- | --- | --- | +| 0 | patch | Patch | Tiny, low-risk fix with obvious validation | Focused change plus validation evidence | +| 1 | scoped-change | Scoped change | Bounded task with clear acceptance criteria | Task contract, acceptance criteria, local validation | +| 2 | feature | Feature | Multi-step delivery with clear solution direction | Spec/plan, task sequence or workstream, validation gate | +| 3 | uncertain-feature | Uncertain feature | Feature with meaningful unknowns | Uncertainty statement, probe decision, probe evidence before build commitment | +| 4 | multi-stream | Multi-stream delivery | Concurrent streams or coordination/collision risk | Workstream map, conflict zones or leases, handoff summaries, sync/drift checks when relevant | + +## Validation posture + +`operating-modes.json` is the canonical machine-readable contract. `npm run check:operating-modes` verifies that modes 0 through 4 are present, ordered, uniquely named, and documented with requirements. + +Modes are additive for now. Existing artifacts do not need an `operating_mode` field until stricter migration work lands. diff --git a/.agents/rules/frontmatter-operations.md b/.agents/rules/frontmatter-operations.md new file mode 100644 index 0000000..f6cf0c6 --- /dev/null +++ b/.agents/rules/frontmatter-operations.md @@ -0,0 +1,6 @@ +# Frontmatter Rule + +- Contract keys in handbook templates are mandatory. +- Do not remove required keys. +- Status values must stay inside the canonical status set. +- Frontmatter must remain the first block in contract files. diff --git a/.agents/rules/github-operations.md b/.agents/rules/github-operations.md new file mode 100644 index 0000000..7f00e17 --- /dev/null +++ b/.agents/rules/github-operations.md @@ -0,0 +1,5 @@ +# GitHub Safety Rule + +- Verify target remote before issue or PR mutations. +- Avoid destructive operations without explicit confirmation. +- Merge only after required quality gates and review semantics are satisfied. diff --git a/.agents/rules/path-standards.md b/.agents/rules/path-standards.md new file mode 100644 index 0000000..2cfae3d --- /dev/null +++ b/.agents/rules/path-standards.md @@ -0,0 +1,5 @@ +# Path Privacy Rule + +- Do not store absolute user-specific paths in shared project artifacts. +- Prefer repository-relative paths in docs and logs. +- Run `.agents/scripts/check-path-standards.sh` before publishing major updates. diff --git a/.agents/rules/test-execution.md b/.agents/rules/test-execution.md new file mode 100644 index 0000000..7ca962a --- /dev/null +++ b/.agents/rules/test-execution.md @@ -0,0 +1,5 @@ +# Test Hygiene Rule + +- Run quality gates based on risk profile. +- Persist execution evidence in task evidence logs and `.agents/logs`. +- Do not bypass required quality checks silently. diff --git a/.agents/rules/worktree-operations.md b/.agents/rules/worktree-operations.md new file mode 100644 index 0000000..00e018f --- /dev/null +++ b/.agents/rules/worktree-operations.md @@ -0,0 +1,5 @@ +# Branch and Worktree Rule + +- Keep one stream owner per shared file at a time. +- Sequence shared contract edits instead of concurrent mutation. +- Escalate contested file ownership immediately. diff --git a/.agents/schemas/README.md b/.agents/schemas/README.md new file mode 100644 index 0000000..9d016f4 --- /dev/null +++ b/.agents/schemas/README.md @@ -0,0 +1,22 @@ +# Delano artifact schema scope + +This directory defines the first contract surface for Delano project artifacts. + +`artifact-scope.json` defines which artifacts are in scope, which fields are required or optional, which fields should become enum-constrained, and which values can be derived by tooling later. + +`artifacts/*.schema.json` contains the first JSON Schema contracts for each scoped artifact type. The schemas are intentionally additive: they make canonical fields explicit without forbidding current project-specific metadata. + +## In scope for the first schema pass + +- Project specs +- Project plans +- Workstreams +- Tasks +- Decision logs +- Updates +- Context documents +- Evidence records in task logs or update files + +## Validation posture + +The scope and schema contracts are additive and local-first. Existing validation still runs through `bash .agents/scripts/pm/validate.sh`; schema-specific validation starts with `npm run check:artifact-scope` and `npm run check:artifact-schemas` before stricter artifact-instance enforcement is added. diff --git a/.agents/schemas/artifact-scope.json b/.agents/schemas/artifact-scope.json new file mode 100644 index 0000000..9049e64 --- /dev/null +++ b/.agents/schemas/artifact-scope.json @@ -0,0 +1,237 @@ +{ + "schema_version": 1, + "status": "draft", + "purpose": "Defines the first enforceable Delano artifact contract surface before JSON schemas are introduced.", + "artifact_types": { + "spec": { + "path_patterns": [ + ".project/projects//spec.md" + ], + "frontmatter": true, + "required_fields": [ + "name", + "slug", + "owner", + "status", + "created", + "updated", + "outcome", + "uncertainty", + "probe_required", + "probe_status" + ], + "optional_fields": [ + "source_review", + "external_reference", + "target_version", + "operating_mode" + ], + "enum_fields": { + "status": [ + "planned", + "active", + "complete", + "deferred" + ], + "probe_required": [ + "true", + "false" + ], + "probe_status": [ + "not-started", + "pending", + "completed", + "skipped" + ] + }, + "derived_fields": [ + "slug may be derived from the project directory name when absent in future migration tooling" + ], + "schema_path": ".agents/schemas/artifacts/spec.schema.json" + }, + "plan": { + "path_patterns": [ + ".project/projects//plan.md" + ], + "frontmatter": true, + "required_fields": [ + "name", + "status", + "lead", + "created", + "updated", + "linear_project_id", + "risk_level", + "spec_status_at_plan_time" + ], + "optional_fields": [ + "target_version", + "operating_mode" + ], + "enum_fields": { + "status": [ + "planned", + "active", + "done", + "deferred" + ], + "risk_level": [ + "low", + "medium", + "high" + ] + }, + "derived_fields": [ + "spec_status_at_plan_time is copied from spec.status when the plan is created" + ], + "schema_path": ".agents/schemas/artifacts/plan.schema.json" + }, + "workstream": { + "path_patterns": [ + ".project/projects//workstreams/*.md" + ], + "frontmatter": true, + "required_fields": [ + "name", + "status", + "owner", + "created", + "updated" + ], + "optional_fields": [ + "id", + "child_project", + "operating_mode" + ], + "enum_fields": { + "status": [ + "planned", + "active", + "done", + "deferred" + ] + }, + "derived_fields": [ + "id may be derived from a canonical filename prefix like WS-A" + ], + "schema_path": ".agents/schemas/artifacts/workstream.schema.json" + }, + "task": { + "path_patterns": [ + ".project/projects//tasks/*.md" + ], + "frontmatter": true, + "required_fields": [ + "id", + "name", + "status", + "workstream", + "created", + "updated", + "linear_issue_id", + "github_issue", + "github_pr", + "depends_on", + "conflicts_with", + "parallel", + "priority", + "estimate" + ], + "optional_fields": [ + "operating_mode" + ], + "enum_fields": { + "status": [ + "ready", + "in-progress", + "blocked", + "done", + "deferred" + ], + "parallel": [ + "true", + "false" + ], + "priority": [ + "low", + "medium", + "high" + ], + "estimate": [ + "S", + "M", + "L", + "XL" + ] + }, + "derived_fields": [ + "workstream must match a workstream id available in the same project" + ], + "schema_path": ".agents/schemas/artifacts/task.schema.json" + }, + "decision_log": { + "path_patterns": [ + ".project/projects//decisions.md" + ], + "frontmatter": false, + "required_sections": [ + "# Decisions" + ], + "optional_fields": [], + "enum_fields": {}, + "derived_fields": [], + "schema_path": ".agents/schemas/artifacts/decision_log.schema.json" + }, + "update": { + "path_patterns": [ + ".project/projects//updates/**/*.md" + ], + "frontmatter": "optional", + "required_sections": [], + "optional_fields": [ + "date", + "related_task", + "status" + ], + "enum_fields": {}, + "derived_fields": [ + "related_task should reference a local task id when present" + ], + "schema_path": ".agents/schemas/artifacts/update.schema.json" + }, + "context": { + "path_patterns": [ + ".project/context/*.md" + ], + "frontmatter": false, + "required_sections": [], + "optional_fields": [], + "enum_fields": {}, + "derived_fields": [], + "schema_path": ".agents/schemas/artifacts/context.schema.json" + }, + "evidence": { + "path_patterns": [ + "Evidence Log sections inside task files", + ".project/projects//updates/**/*.md" + ], + "frontmatter": false, + "required_fields": [ + "timestamp", + "claim", + "validation_or_artifact" + ], + "optional_fields": [ + "command", + "result", + "log_path", + "commit" + ], + "enum_fields": {}, + "derived_fields": [ + "timestamp is the leading ISO8601 UTC value in a task Evidence Log bullet" + ], + "schema_path": ".agents/schemas/artifacts/evidence.schema.json" + } + } +} diff --git a/.agents/schemas/artifacts/context.schema.json b/.agents/schemas/artifacts/context.schema.json new file mode 100644 index 0000000..9eee8c9 --- /dev/null +++ b/.agents/schemas/artifacts/context.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/artifacts/context.schema.json", + "title": "Delano context document", + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "path": { "type": "string", "pattern": "^\\.project/context/[^/]+\\.md$" } + } +} diff --git a/.agents/schemas/artifacts/decision_log.schema.json b/.agents/schemas/artifacts/decision_log.schema.json new file mode 100644 index 0000000..791c7b4 --- /dev/null +++ b/.agents/schemas/artifacts/decision_log.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/artifacts/decision-log.schema.json", + "title": "Delano decision log document", + "type": "object", + "additionalProperties": false, + "required": ["path", "required_sections"], + "properties": { + "path": { "type": "string", "pattern": "^\\.project/projects/[^/]+/decisions\\.md$" }, + "required_sections": { "type": "array", "contains": { "const": "# Decisions" } } + } +} diff --git a/.agents/schemas/artifacts/evidence.schema.json b/.agents/schemas/artifacts/evidence.schema.json new file mode 100644 index 0000000..7a9f270 --- /dev/null +++ b/.agents/schemas/artifacts/evidence.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/artifacts/evidence.schema.json", + "title": "Delano evidence record", + "type": "object", + "additionalProperties": true, + "required": ["timestamp", "claim", "validation_or_artifact"], + "properties": { + "timestamp": { "type": "string", "format": "date-time" }, + "claim": { "type": "string", "minLength": 1 }, + "validation_or_artifact": { "type": "string", "minLength": 1 }, + "command": { "type": "string" }, + "result": { "type": "string" }, + "log_path": { "type": "string" }, + "commit": { "type": "string", "pattern": "^[0-9a-f]{7,40}$" } + } +} diff --git a/.agents/schemas/artifacts/plan.schema.json b/.agents/schemas/artifacts/plan.schema.json new file mode 100644 index 0000000..aeec77e --- /dev/null +++ b/.agents/schemas/artifacts/plan.schema.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/artifacts/plan.schema.json", + "title": "Delano project plan frontmatter", + "type": "object", + "additionalProperties": true, + "required": [ + "name", + "status", + "lead", + "created", + "updated", + "linear_project_id", + "risk_level", + "spec_status_at_plan_time" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "planned", + "active", + "done", + "deferred" + ] + }, + "lead": { + "type": "string", + "minLength": 1 + }, + "created": { + "type": "string", + "format": "date-time" + }, + "updated": { + "type": "string", + "format": "date-time" + }, + "linear_project_id": { + "type": "string" + }, + "risk_level": { + "enum": [ + "low", + "medium", + "high" + ] + }, + "spec_status_at_plan_time": { + "enum": [ + "planned", + "active", + "complete", + "deferred" + ] + }, + "target_version": { + "type": "string" + }, + "operating_mode": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + "0", + "1", + "2", + "3", + "4", + "patch", + "scoped-change", + "feature", + "uncertain-feature", + "multi-stream" + ] + } + } +} diff --git a/.agents/schemas/artifacts/spec.schema.json b/.agents/schemas/artifacts/spec.schema.json new file mode 100644 index 0000000..d976d50 --- /dev/null +++ b/.agents/schemas/artifacts/spec.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/artifacts/spec.schema.json", + "title": "Delano project spec frontmatter", + "type": "object", + "additionalProperties": true, + "required": [ + "name", + "slug", + "owner", + "status", + "created", + "updated", + "outcome", + "uncertainty", + "probe_required", + "probe_status" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "slug": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "planned", + "active", + "complete", + "deferred" + ] + }, + "created": { + "type": "string", + "format": "date-time" + }, + "updated": { + "type": "string", + "format": "date-time" + }, + "outcome": { + "type": "string", + "minLength": 1 + }, + "uncertainty": { + "type": "string", + "minLength": 1 + }, + "probe_required": { + "enum": [ + "true", + "false", + true, + false + ] + }, + "probe_status": { + "enum": [ + "not-started", + "pending", + "completed", + "skipped" + ] + }, + "source_review": { + "type": "string" + }, + "external_reference": { + "type": "string" + }, + "target_version": { + "type": "string" + }, + "operating_mode": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + "0", + "1", + "2", + "3", + "4", + "patch", + "scoped-change", + "feature", + "uncertain-feature", + "multi-stream" + ] + } + } +} diff --git a/.agents/schemas/artifacts/task.schema.json b/.agents/schemas/artifacts/task.schema.json new file mode 100644 index 0000000..bc289c9 --- /dev/null +++ b/.agents/schemas/artifacts/task.schema.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/artifacts/task.schema.json", + "title": "Delano task frontmatter", + "type": "object", + "additionalProperties": true, + "required": [ + "id", + "name", + "status", + "workstream", + "created", + "updated", + "linear_issue_id", + "github_issue", + "github_pr", + "depends_on", + "conflicts_with", + "parallel", + "priority", + "estimate" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^T-[0-9]{3}$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "ready", + "in-progress", + "blocked", + "done", + "deferred" + ] + }, + "workstream": { + "type": "string", + "pattern": "^WS-[A-Za-z0-9]+$" + }, + "created": { + "type": "string", + "format": "date-time" + }, + "updated": { + "type": "string", + "format": "date-time" + }, + "linear_issue_id": { + "type": "string" + }, + "github_issue": { + "type": "string" + }, + "github_pr": { + "type": "string" + }, + "depends_on": { + "type": "array", + "items": { + "type": "string", + "pattern": "^T-[0-9]{3}$" + }, + "uniqueItems": true + }, + "conflicts_with": { + "type": "array", + "items": { + "type": "string", + "pattern": "^T-[0-9]{3}$" + }, + "uniqueItems": true + }, + "parallel": { + "enum": [ + "true", + "false", + true, + false + ] + }, + "priority": { + "enum": [ + "low", + "medium", + "high" + ] + }, + "estimate": { + "enum": [ + "S", + "M", + "L", + "XL" + ] + }, + "operating_mode": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + "0", + "1", + "2", + "3", + "4", + "patch", + "scoped-change", + "feature", + "uncertain-feature", + "multi-stream" + ] + } + } +} diff --git a/.agents/schemas/artifacts/update.schema.json b/.agents/schemas/artifacts/update.schema.json new file mode 100644 index 0000000..e73e695 --- /dev/null +++ b/.agents/schemas/artifacts/update.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/artifacts/update.schema.json", + "title": "Delano update frontmatter", + "type": "object", + "additionalProperties": true, + "properties": { + "date": { "type": "string", "format": "date-time" }, + "related_task": { "type": "string", "pattern": "^T-[0-9]{3}$" }, + "status": { "type": "string", "minLength": 1 } + } +} diff --git a/.agents/schemas/artifacts/workstream.schema.json b/.agents/schemas/artifacts/workstream.schema.json new file mode 100644 index 0000000..758bb66 --- /dev/null +++ b/.agents/schemas/artifacts/workstream.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/artifacts/workstream.schema.json", + "title": "Delano workstream frontmatter", + "type": "object", + "additionalProperties": true, + "required": [ + "name", + "status", + "owner", + "created", + "updated" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^WS-[A-Za-z0-9]+$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "planned", + "active", + "done", + "deferred" + ] + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "created": { + "type": "string", + "format": "date-time" + }, + "updated": { + "type": "string", + "format": "date-time" + }, + "child_project": { + "type": "string" + }, + "operating_mode": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + "0", + "1", + "2", + "3", + "4", + "patch", + "scoped-change", + "feature", + "uncertain-feature", + "multi-stream" + ] + } + } +} diff --git a/.agents/schemas/evidence-map.json b/.agents/schemas/evidence-map.json new file mode 100644 index 0000000..61da4bf --- /dev/null +++ b/.agents/schemas/evidence-map.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "status": "draft", + "purpose": "Maps task acceptance criteria to proof signals required before a task can be treated as done.", + "done_task_rules": [ + { + "id": "acceptance-criteria-checked", + "description": "Done tasks must have no unchecked acceptance criteria." + }, + { + "id": "evidence-log-present", + "description": "Done tasks must have an Evidence Log section with at least one implementation evidence item." + }, + { + "id": "repo-inspection-proof", + "acceptance_criterion_contains": "repo state has been inspected", + "proof_terms": [ + "inspected" + ] + }, + { + "id": "representation-proof", + "acceptance_criterion_contains": "represented in Delano runtime assets", + "proof_terms": [ + "added", + "updated", + "wired", + "rebuilt", + "represented", + "mirrored" + ] + }, + { + "id": "validation-proof", + "acceptance_criterion_contains": "validated with the smallest meaningful command", + "proof_terms": [ + "validation passed", + "passed:", + "npm test", + "check:", + "Validation:" + ] + }, + { + "id": "evidence-recorded-proof", + "acceptance_criterion_contains": "Evidence is recorded", + "proof_terms": [ + "Evidence Log" + ] + } + ], + "strict_since": "2026-04-29T00:00:00Z" +} diff --git a/.agents/schemas/learning/closeout-learning-proposal.schema.json b/.agents/schemas/learning/closeout-learning-proposal.schema.json new file mode 100644 index 0000000..f2a4dad --- /dev/null +++ b/.agents/schemas/learning/closeout-learning-proposal.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/learning/closeout-learning-proposal.schema.json", + "title": "Delano closeout learning proposal", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "project", "task_ids", "proposal_type", "title", "rationale", "target_paths", "review_gate", "adoption_status"], + "properties": { + "schema_version": { "const": 1 }, + "project": { "type": "string", "minLength": 1 }, + "task_ids": { "type": "array", "items": { "type": "string", "pattern": "^T-[0-9]{3}$" }, "minItems": 1 }, + "proposal_type": { "enum": ["rule", "skill", "schema", "fixture"] }, + "title": { "type": "string", "minLength": 1 }, + "rationale": { "type": "string", "minLength": 1 }, + "target_paths": { "type": "array", "items": { "type": "string", "not": { "pattern": "^/" } }, "minItems": 1 }, + "review_gate": { "enum": ["required-before-adoption"] }, + "adoption_status": { "enum": ["proposed", "accepted", "rejected", "deferred"] }, + "evidence": { "type": "array", "items": { "type": "string" } } + } +} diff --git a/.agents/schemas/learning/delivery-metric-event.schema.json b/.agents/schemas/learning/delivery-metric-event.schema.json new file mode 100644 index 0000000..9981d7a --- /dev/null +++ b/.agents/schemas/learning/delivery-metric-event.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/learning/delivery-metric-event.schema.json", + "title": "Delano delivery metric event", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "event_id", "event_type", "project", "task_id", "occurred_at", "privacy"], + "properties": { + "schema_version": { "const": 1 }, + "event_id": { "type": "string", "pattern": "^evt-[0-9TZ-]+-[a-z0-9-]+$" }, + "event_type": { "enum": ["task_started", "validation_passed", "validation_failed", "task_completed", "blocked", "handoff_created"] }, + "project": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "task_id": { "type": "string", "pattern": "^T-[0-9]{3}$" }, + "occurred_at": { "type": "string" }, + "duration_seconds": { "type": "number", "minimum": 0 }, + "validation_command": { "type": "string" }, + "outcome": { "enum": ["pass", "fail", "blocked", "info"] }, + "privacy": { "enum": ["summary-only", "local-detail"] }, + "notes": { "type": "string" } + } +} diff --git a/.agents/schemas/leases/lease.schema.json b/.agents/schemas/leases/lease.schema.json new file mode 100644 index 0000000..dea4f20 --- /dev/null +++ b/.agents/schemas/leases/lease.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/leases/lease.schema.json", + "title": "Delano multi-agent path lease", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "lease_id", "owner", "project", "task_id", "status", "mode", "paths", "conflict_zones", "created_at", "acquired_at", "expires_at"], + "properties": { + "schema_version": { "const": 1 }, + "lease_id": { "type": "string", "pattern": "^lease-[0-9TZ._-]+-[a-z0-9-]+$" }, + "owner": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "additionalProperties": false, + "required": ["agent_id", "stream"], + "properties": { + "agent_id": { "type": "string", "minLength": 1 }, + "stream": { "type": "string", "minLength": 1 }, + "task": { "type": "string" } + } + } + ] + }, + "project": { "type": "string", "minLength": 1 }, + "task_id": { "type": "string", "pattern": "^T-[0-9]{3}$" }, + "mode": { "enum": ["shared", "exclusive"] }, + "paths": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1, "not": { "pattern": "^/" } } }, + "conflict_zones": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1, "not": { "pattern": "^/" } } }, + "created_at": { "type": "string" }, + "acquired_at": { "type": "string" }, + "expires_at": { "type": "string" }, + "released_at": { "type": "string" }, + "status": { "enum": ["active", "expired", "released"] }, + "release_reason": { "type": "string" }, + "handoff_summary": { "type": "string" } + } +} diff --git a/.agents/schemas/metrics/delivery-event.schema.json b/.agents/schemas/metrics/delivery-event.schema.json new file mode 100644 index 0000000..2452772 --- /dev/null +++ b/.agents/schemas/metrics/delivery-event.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/metrics/delivery-event.schema.json", + "title": "Delano privacy-safe delivery metric event", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "event_id", "event_type", "project", "created_at", "source", "privacy", "summary"], + "properties": { + "schema_version": { "const": 1 }, + "event_id": { "type": "string", "pattern": "^metric-[0-9TZ._-]+-[a-z0-9-]+$" }, + "event_type": { "enum": ["task-status-change", "validation-run", "sync-drift", "evidence-gap", "blocked-time", "closeout-learning"] }, + "project": { "type": "string", "minLength": 1 }, + "task_id": { "type": "string", "pattern": "^T-[0-9]{3}$" }, + "created_at": { "type": "string" }, + "source": { "enum": ["local-runtime", "pm-validation", "sync-inspection", "closeout"] }, + "privacy": { + "type": "object", + "additionalProperties": false, + "required": ["raw_text_allowed", "summary_only", "redaction_required"], + "properties": { + "raw_text_allowed": { "const": false }, + "summary_only": { "const": true }, + "redaction_required": { "const": true } + } + }, + "summary": { "type": "string", "minLength": 1 }, + "metrics": { "type": "object", "additionalProperties": { "type": ["number", "string", "boolean"] } } + } +} diff --git a/.agents/schemas/metrics/delivery-events.schema.json b/.agents/schemas/metrics/delivery-events.schema.json new file mode 100644 index 0000000..41673cf --- /dev/null +++ b/.agents/schemas/metrics/delivery-events.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/metrics/delivery-events.schema.json", + "title": "Delano delivery metric event contract", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "event_id", "event_type", "timestamp", "project", "actor", "summary"], + "properties": { + "schema_version": { "const": 1 }, + "event_id": { "type": "string", "pattern": "^evt-[0-9TZ._-]+-[a-z0-9-]+$" }, + "event_type": { + "enum": [ + "task_status_changed", + "validation_run", + "lease_acquired", + "lease_released", + "drift_report_generated", + "repair_plan_created", + "closeout_recorded" + ] + }, + "timestamp": { "type": "string" }, + "project": { "type": "string", "minLength": 1 }, + "task_id": { "type": "string", "pattern": "^T-[0-9]{3}$" }, + "actor": { "enum": ["agent", "system", "user"] }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["label", "privacy"], + "properties": { + "label": { "type": "string", "minLength": 1 }, + "privacy": { "const": "metadata-only" }, + "counts": { "type": "object", "additionalProperties": { "type": "integer", "minimum": 0 } }, + "status_from": { "type": "string" }, + "status_to": { "type": "string" }, + "result": { "enum": ["passed", "failed", "blocked", "warning", "generated"] } + } + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "properties": { + "repo_paths": { "type": "array", "items": { "type": "string", "not": { "pattern": "^/" } } }, + "commands": { "type": "array", "items": { "type": "string" } }, + "commit": { "type": "string", "pattern": "^[0-9a-f]{7,40}$" } + } + } + } +} diff --git a/.agents/schemas/operating-modes.json b/.agents/schemas/operating-modes.json new file mode 100644 index 0000000..0ae5b07 --- /dev/null +++ b/.agents/schemas/operating-modes.json @@ -0,0 +1,42 @@ +{ + "schema_version": 1, + "status": "draft", + "purpose": "Defines Delano operating modes used to size governance, evidence, and coordination overhead for delivery work.", + "modes": [ + { + "mode": 0, + "slug": "patch", + "name": "Patch", + "use_when": "A small, well-understood correction with low uncertainty and no meaningful coordination surface.", + "requires": ["single task or direct patch", "focused validation evidence", "no new workstream unless needed"] + }, + { + "mode": 1, + "slug": "scoped-change", + "name": "Scoped change", + "use_when": "A bounded change with clear acceptance criteria, known affected files, and no material discovery risk.", + "requires": ["task contract", "acceptance criteria evidence", "local validation"] + }, + { + "mode": 2, + "slug": "feature", + "name": "Feature", + "use_when": "A feature-sized delivery with multiple implementation steps but a clear solution direction.", + "requires": ["spec and plan", "workstream or task sequence", "validation gate before done"] + }, + { + "mode": 3, + "slug": "uncertain-feature", + "name": "Uncertain feature", + "use_when": "A feature where the solution, user value, or integration path is uncertain enough to require a probe first.", + "requires": ["uncertainty statement", "probe_required=true or explicit skip rationale", "probe evidence before build commitment"] + }, + { + "mode": 4, + "slug": "multi-stream", + "name": "Multi-stream delivery", + "use_when": "Delivery spans concurrent streams, leases, external sync, or coordination boundaries where collision risk matters.", + "requires": ["workstream map", "conflict zones or leases", "handoff summaries", "sync/drift checks when external systems are involved"] + } + ] +} diff --git a/.agents/schemas/status-transitions.json b/.agents/schemas/status-transitions.json new file mode 100644 index 0000000..cfa5079 --- /dev/null +++ b/.agents/schemas/status-transitions.json @@ -0,0 +1,66 @@ +{ + "schema_version": 1, + "status": "draft", + "purpose": "Defines local validation rules for Delano task status readiness and blocked-state hygiene.", + "task_rules": [ + { + "id": "ready-dependencies-done", + "status": "ready", + "description": "A ready task must not depend on a local task that is not done.", + "requires": ["all local depends_on task statuses are done"] + }, + { + "id": "in-progress-dependencies-done", + "status": "in-progress", + "description": "An in-progress task must not start while local dependencies remain unresolved.", + "requires": ["all local depends_on task statuses are done"] + }, + { + "id": "done-dependencies-done", + "status": "done", + "description": "A done task must not close over unresolved local dependencies.", + "requires": ["all local depends_on task statuses are done"] + }, + { + "id": "progressed-task-requires-active-project", + "status": "in-progress|done", + "description": "A task must not start or complete while the parent project spec or plan is still planned.", + "requires": [ + "spec.status is active or complete", + "plan.status is active or done" + ] + }, + { + "id": "closed-task-set-requires-closed-project", + "status": "done|deferred", + "description": "A project with no open tasks must not remain open through stale spec or plan statuses.", + "requires": [ + "spec.status is complete or deferred", + "plan.status is done or deferred" + ] + }, + { + "id": "progressed-task-requires-active-workstream", + "status": "in-progress|done", + "description": "A task must not start or complete before its parent workstream has started.", + "requires": [ + "in-progress tasks require workstream.status active", + "done tasks require workstream.status active or done" + ] + }, + { + "id": "closed-task-set-requires-closed-workstream", + "status": "done|deferred", + "description": "A workstream with no open tasks must not remain open through a stale status.", + "requires": [ + "workstream.status is done or deferred" + ] + }, + { + "id": "blocked-owner-check-back", + "status": "blocked", + "description": "A blocked task must name who owns unblocking and when to check back.", + "requires": ["blocked_owner", "blocked_check_back"] + } + ] +} diff --git a/.agents/schemas/sync/drift-report.schema.json b/.agents/schemas/sync/drift-report.schema.json new file mode 100644 index 0000000..0ee8a74 --- /dev/null +++ b/.agents/schemas/sync/drift-report.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/sync/drift-report.schema.json", + "title": "Delano dry-run drift report", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "mode", "generated_at", "summary", "drift", "repair_recommendations"], + "properties": { + "schema_version": { "const": 1 }, + "mode": { "const": "dry-run" }, + "generated_at": { "type": "string" }, + "summary": { + "type": "object", + "required": ["projects", "tasks", "drift_count", "repair_count"], + "properties": { + "projects": { "type": "integer", "minimum": 0 }, + "tasks": { "type": "integer", "minimum": 0 }, + "drift_count": { "type": "integer", "minimum": 0 }, + "repair_count": { "type": "integer", "minimum": 0 } + } + }, + "drift": { "type": "array" }, + "repair_recommendations": { "type": "array" } + } +} diff --git a/.agents/schemas/sync/drift-taxonomy.json b/.agents/schemas/sync/drift-taxonomy.json new file mode 100644 index 0000000..79a04dd --- /dev/null +++ b/.agents/schemas/sync/drift-taxonomy.json @@ -0,0 +1,38 @@ +{ + "schema_version": 1, + "status": "draft", + "purpose": "Typed local-first taxonomy for Delano operational sync drift and repair recommendations.", + "drift_types": [ + { + "id": "mapping-drift", + "description": "A local project/task/workstream mapping disagrees with the registry or external identifiers.", + "severity": ["info", "warning", "error"], + "repair_posture": "dry-run-plan-first" + }, + { + "id": "status-drift", + "description": "Local status and inspected external status differ.", + "severity": ["warning", "error"], + "repair_posture": "dry-run-plan-first" + }, + { + "id": "dependency-drift", + "description": "Local dependencies and inspected external dependency links differ.", + "severity": ["warning", "error"], + "repair_posture": "dry-run-plan-first" + }, + { + "id": "orphan-drift", + "description": "A local or external record lacks its expected counterpart.", + "severity": ["info", "warning", "error"], + "repair_posture": "manual-review-before-apply" + }, + { + "id": "repair-recommendation", + "description": "A proposed local or external repair action generated from drift inspection.", + "severity": ["info", "warning", "error"], + "repair_posture": "never-apply-without-explicit-approval" + } + ], + "repair_recommendation_fields": ["drift_type", "target", "summary", "proposed_action", "apply_posture", "evidence"] +} diff --git a/.agents/schemas/sync/sync-map.schema.json b/.agents/schemas/sync/sync-map.schema.json new file mode 100644 index 0000000..8fb6f8a --- /dev/null +++ b/.agents/schemas/sync/sync-map.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://delano.local/schemas/sync/sync-map.schema.json", + "title": "Delano operational sync map", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "projects"], + "properties": { + "schema_version": { "const": 1 }, + "projects": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["slug", "local_path"], + "properties": { + "slug": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "local_path": { "type": "string", "pattern": "^\\.project/projects/[^/]+$" }, + "linear_project_id": { "type": "string" }, + "github_repo": { "type": "string" }, + "tasks": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["local_id"], + "properties": { + "local_id": { "type": "string", "pattern": "^T-[0-9]{3}$" }, + "linear_issue_id": { "type": "string" }, + "github_issue": { "type": "string" }, + "github_pr": { "type": "string" } + } + } + } + } + } + } + } +} diff --git a/.agents/scripts/README.md b/.agents/scripts/README.md new file mode 100644 index 0000000..5f5e65a --- /dev/null +++ b/.agents/scripts/README.md @@ -0,0 +1,32 @@ +# Delano Runtime Scripts + +This folder contains the script runtime described in `HANDBOOK.md`. + +Canonical path: `.agents/scripts/...` + +Compatibility path: `.claude/scripts/...` when the mirror is present. + +## PM scripts (`.agents/scripts/pm/`) + +Critical path: +- `init.sh` +- `validate.sh` +- `status.sh` (`--open` and `--brief` are available for compact startup context) +- `next.sh` +- `blocked.sh` + +Operational: +- `standup.sh` +- `in-progress.sh` +- `prd-list.sh` +- `epic-list.sh` +- `search.sh` + +## Audit and utility +- `log-event.sh` / `log-event.js` +- `query-log.sh` +- `test-and-log.sh` +- `check-path-standards.sh` +- `check-text-safety.mjs` +- `fix-path-standards.sh` +- `git-sparse-download.sh` diff --git a/.agents/scripts/audit-context-files.mjs b/.agents/scripts/audit-context-files.mjs new file mode 100644 index 0000000..7398e87 --- /dev/null +++ b/.agents/scripts/audit-context-files.mjs @@ -0,0 +1,54 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const contextDir = path.join(repoRoot, ".project", "context"); +const staleDays = Number(readOption("--stale-days") || 180); +const now = Date.now(); +const requiredCommandDocs = new Map([ + ["tech-context.md", ["npm test", "validate.sh"]], + ["progress.md", ["validate.sh"]] +]); + +const files = existsSync(contextDir) ? readdirSync(contextDir).filter((file) => file.endsWith(".md")).sort() : []; +const entries = files.map((file) => auditFile(file)); +const summary = entries.reduce((acc, entry) => { + acc[entry.classification] = (acc[entry.classification] || 0) + 1; + return acc; +}, {}); +const result = { + schema_version: 1, + context_dir: ".project/context", + stale_days: staleDays, + file_count: entries.length, + summary, + files: entries +}; + +if (process.argv.includes("--json")) console.log(JSON.stringify(result, null, 2)); +else console.log(`Context audit scored ${entries.length} file(s): ${Object.entries(summary).map(([k,v])=>`${k}=${v}`).join(", ")}.`); + +function auditFile(file) { + const repoPath = [".project", "context", file].join("/"); + const abs = path.join(repoRoot, ".project", "context", file); + const text = readFileSync(abs, "utf8"); + const requiredCommands = requiredCommandDocs.get(file) || []; + const missingCommands = requiredCommands.filter((command) => !text.includes(command)); + const ageDays = Math.floor((now - statSync(abs).mtimeMs) / 86_400_000); + const lineCount = text.split(/\r?\n/).length; + const wordCount = (text.match(/\b\w+\b/g) || []).length; + const placeholderSignals = countSignals(text, [/\bTODO\b/i, /\bTBD\b/i, /placeholder/i, /fill this/i, /coming soon/i]); + let classification = "real"; + if (file === "README.md") classification = "not_applicable"; + else if (wordCount < 40 || placeholderSignals >= 2) classification = "placeholder"; + else if (missingCommands.length) classification = "missing_required_commands"; + else if (ageDays > staleDays) classification = "stale"; + const score = classification === "real" ? 100 : classification === "stale" ? 65 : classification === "missing_required_commands" ? 55 : classification === "placeholder" ? 25 : 0; + return { path: repoPath, classification, score, age_days: ageDays, line_count: lineCount, word_count: wordCount, missing_required_commands: missingCommands }; +} +function countSignals(text, patterns) { return patterns.reduce((sum, pattern) => sum + (pattern.test(text) ? 1 : 0), 0); } +function readOption(name) { const i = process.argv.indexOf(name); return i === -1 ? "" : process.argv[i + 1]; } +function resolveRepoRoot(startDir) { for (const c of [path.resolve(startDir,".."),path.resolve(startDir,"..","..")]) if (existsSync(path.join(c,".project"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/audit-context-scoring.mjs b/.agents/scripts/audit-context-scoring.mjs new file mode 100644 index 0000000..f7f45d1 --- /dev/null +++ b/.agents/scripts/audit-context-scoring.mjs @@ -0,0 +1,14 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const targets = ["AGENTS.md", ".project/context", ".project/registry/linear-map.json", ".agents/scripts/pm/validate.sh"]; +const findings = targets.map(scoreTarget); +const summary = { real:0, placeholder:0, stale:0, missing:0, not_applicable:0 }; +for (const f of findings) summary[f.classification]++; +const result = { schema_version: 1, score: findings.filter(f=>f.classification === "real").length, max_score: findings.length, summary, findings }; +if (process.argv.includes("--json")) console.log(JSON.stringify(result,null,2)); else console.log(`Context audit score ${result.score}/${result.max_score}; missing=${summary.missing}; placeholder=${summary.placeholder}.`); +if (summary.missing || summary.placeholder) process.exit(1); +function scoreTarget(rel){ const abs=path.join(repoRoot, rel); if(!existsSync(abs)) return { path: rel, classification: "missing", reason: "required context path is absent" }; const st=statSync(abs); if(st.isDirectory()) return { path: rel, classification: "real", reason: "directory present" }; const text=readFileSync(abs,"utf8"); if(/TODO|placeholder|coming soon/i.test(text) && text.length < 200) return { path: rel, classification: "placeholder", reason: "short placeholder text" }; if(rel.endsWith('validate.sh') && !text.includes('Summary')) return { path: rel, classification: "stale", reason: "validation script lacks summary section" }; return { path: rel, classification: "real", reason: "required context content present" }; } +function resolveRepoRoot(startDir){ for(const c of [path.resolve(startDir,".."),path.resolve(startDir,"..","..")]) if(existsSync(path.join(c,".project"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/build-drift-report.mjs b/.agents/scripts/build-drift-report.mjs new file mode 100644 index 0000000..452ee23 --- /dev/null +++ b/.agents/scripts/build-drift-report.mjs @@ -0,0 +1,133 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readLocalSyncMap } from "./read-local-sync-map.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const jsonMode = process.argv.includes("--json"); +const localOnlyMode = process.argv.includes("--local-only") || (!process.argv.includes("--github-snapshot") && !process.argv.includes("--linear-snapshot")); +const githubSnapshotPath = readOption("--github-snapshot") || path.join(repoRoot, ".agents", "fixtures", "github", "status-snapshot.json"); +const linearSnapshotPath = readOption("--linear-snapshot") || path.join(repoRoot, ".agents", "fixtures", "linear", "issue-snapshot.json"); + +if (isDirectRun()) { + const syncMap = readLocalSyncMap(repoRoot); + const githubSnapshot = localOnlyMode ? { repositories: [] } : readJson(githubSnapshotPath, { repositories: [] }); + const linearSnapshot = localOnlyMode ? { issues: [] } : readJson(linearSnapshotPath, { issues: [] }); + const report = buildDriftReport(syncMap, githubSnapshot, linearSnapshot, { localOnlyMode }); + + if (jsonMode) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(`Dry-run drift report produced ${report.summary.drift_count} drift item(s) from ${report.summary.tasks} task(s).`); + console.log(`Mode: ${report.mode}; apply posture: ${report.apply_posture}.`); + for (const drift of report.drift) console.log(`- ${drift.severity}: ${drift.target} ${drift.summary}`); + } +} +export function buildDriftReport(syncMap, githubSnapshot = {}, linearSnapshot = {}, options = {}) { + const githubIndex = indexGithubSnapshot(githubSnapshot); + const linearIndex = new Map((linearSnapshot.issues || []).map((issue) => [String(issue.id), issue])); + const drift = []; + let taskCount = 0; + let inspectedRefs = 0; + + for (const project of syncMap.projects || []) { + for (const task of project.tasks || []) { + taskCount += 1; + const taskKey = `${project.slug}/${task.local_id}`; + + for (const [field, expectedKind] of [["github_issue", "issue"], ["github_pr", "pull_request"]]) { + if (!task[field]) continue; + inspectedRefs += 1; + const parsed = parseGitHubRef(task[field], project.github_repo, expectedKind); + if (!parsed) { + drift.push(driftItem("mapping-drift", "error", taskKey, `invalid ${field}: ${task[field]}`, "Correct or remove the malformed GitHub reference.", { field, value: task[field] })); + continue; + } + const remote = githubIndex.get(`${parsed.owner}/${parsed.repo}#${parsed.number}:${parsed.kind}`); + if (!remote) { + drift.push(driftItem("orphan-drift", "warning", taskKey, `${field} has no inspected GitHub counterpart`, "Review the reference or refresh the GitHub snapshot before applying changes.", { field, ref: task[field], expected_kind: expectedKind })); + } else if (remote.kind && remote.kind !== expectedKind) { + drift.push(driftItem("mapping-drift", "error", taskKey, `${field} points to ${remote.kind}, expected ${expectedKind}`, "Move the reference to the correct local field or repair the remote mapping.", { field, ref: task[field], external_kind: remote.kind })); + } + } + + if (task.linear_issue_id) { + inspectedRefs += 1; + const issue = linearIndex.get(String(task.linear_issue_id)); + if (!issue) { + drift.push(driftItem("orphan-drift", "warning", taskKey, "linear_issue_id has no inspected Linear counterpart", "Review the reference or refresh the Linear snapshot before applying changes.", { linear_issue_id: task.linear_issue_id })); + } else if (issue.project_id && project.linear_project_id && issue.project_id !== project.linear_project_id) { + drift.push(driftItem("mapping-drift", "error", taskKey, `Linear project mismatch: ${issue.project_id} != ${project.linear_project_id}`, "Plan a project mapping repair before sync apply.", { linear_issue_id: task.linear_issue_id, external_project_id: issue.project_id, local_project_id: project.linear_project_id })); + } + } + } + } + + const repairRecommendations = drift.map((item, index) => ({ + id: `RR-${String(index + 1).padStart(3, "0")}`, + drift_type: item.drift_type, + target: item.target, + summary: item.summary, + proposed_action: item.proposed_action, + apply_posture: item.apply_posture, + evidence: item.evidence + })); + + return { + schema_version: 1, + mode: "dry-run", + generated_at: new Date().toISOString(), + source: options.localOnlyMode ? "local-sync-map-only" : "local-sync-map-and-fixtures", + apply_posture: "never-apply-without-explicit-approval", + summary: { + projects: (syncMap.projects || []).length, + tasks: taskCount, + inspected_refs: inspectedRefs, + drift_count: drift.length, + repair_count: repairRecommendations.length + }, + drift, + repair_recommendations: repairRecommendations + }; +} + +function driftItem(driftType, severity, target, summary, proposedAction, evidence) { + return { drift_type: driftType, severity, target, summary, proposed_action: proposedAction, apply_posture: "dry-run-plan-first", evidence }; +} +function parseGitHubRef(value, fallbackRepo, expectedKind) { + const raw = String(value || "").trim(); + const url = raw.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/([0-9]+)\/?$/); + if (url) return { owner: url[1], repo: url[2], kind: url[3] === "pull" ? "pull_request" : "issue", number: Number(url[4]) }; + const short = raw.match(/^#?([0-9]+)$/); + if (short && fallbackRepo?.includes("/")) { + const [owner, repo] = fallbackRepo.split("/"); + return { owner, repo, kind: expectedKind, number: Number(short[1]) }; + } + return null; +} +function indexGithubSnapshot(snapshot) { + const index = new Map(); + for (const repo of snapshot.repositories || []) { + for (const issue of repo.issues || []) index.set(`${repo.owner}/${repo.name}#${issue.number}:issue`, { ...issue, kind: "issue" }); + for (const pr of repo.pull_requests || []) index.set(`${repo.owner}/${repo.name}#${pr.number}:pull_request`, { ...pr, kind: "pull_request" }); + } + return index; +} +function readOption(name) { + const index = process.argv.indexOf(name); + return index === -1 ? "" : process.argv[index + 1]; +} +function readJson(filePath, fallback) { + if (!existsSync(filePath)) return fallback; + return JSON.parse(readFileSync(filePath, "utf8")); +} +function isDirectRun() { + return process.argv[1] && path.resolve(process.argv[1]) === __filename; +} +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) if (existsSync(path.join(candidate, ".project")) && existsSync(path.join(candidate, ".agents"))) return candidate; + return path.resolve(startDir, ".."); +} diff --git a/.agents/scripts/check-artifact-schemas.mjs b/.agents/scripts/check-artifact-schemas.mjs new file mode 100644 index 0000000..66e14a3 --- /dev/null +++ b/.agents/scripts/check-artifact-schemas.mjs @@ -0,0 +1,116 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const scopePath = path.join(repoRoot, ".agents", "schemas", "artifact-scope.json"); +const errors = []; + +const scope = readJson(scopePath, "artifact scope"); +const artifactTypes = scope.artifact_types || {}; + +for (const [artifactType, contract] of Object.entries(artifactTypes)) { + const schemaPath = contract.schema_path; + if (typeof schemaPath !== "string" || schemaPath.trim() === "") { + errors.push(`${artifactType} must declare schema_path in artifact-scope.json.`); + continue; + } + + const absoluteSchemaPath = path.join(repoRoot, schemaPath); + const schema = readJson(absoluteSchemaPath, `${artifactType} schema`); + if (!existsSync(absoluteSchemaPath)) { + continue; + } + + checkSchemaBasics(artifactType, schemaPath, schema); + checkRequiredFields(artifactType, contract, schema); + checkEnumFields(artifactType, contract, schema); +} + +if (errors.length > 0) { + console.error("Artifact schema check failed:"); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); +} + +console.log(`Artifact schema check passed for ${Object.keys(artifactTypes).length} artifact schemas.`); + +function readJson(filePath, label) { + try { + return JSON.parse(readFileSync(filePath, "utf8")); + } catch (error) { + errors.push(`Could not read ${label} at ${toRepoPath(filePath)}: ${error.message}`); + return {}; + } +} + +function checkSchemaBasics(artifactType, schemaPath, schema) { + if (!schema.$schema) { + errors.push(`${schemaPath} must declare $schema.`); + } + if (!schema.$id) { + errors.push(`${schemaPath} must declare $id.`); + } + if (schema.type !== "object") { + errors.push(`${schemaPath} must describe an object schema.`); + } + if (!schema.title || !schema.title.toLowerCase().includes("delano")) { + errors.push(`${schemaPath} must include a Delano-specific title.`); + } + if (artifactType !== path.basename(schemaPath, ".schema.json")) { + errors.push(`${schemaPath} file name must match artifact type ${artifactType}.`); + } +} + +function checkRequiredFields(artifactType, contract, schema) { + const expected = contract.required_fields || []; + const actual = schema.required || []; + for (const field of expected) { + if (!actual.includes(field)) { + errors.push(`${artifactType} schema must require canonical field: ${field}`); + } + if (!schema.properties || !schema.properties[field]) { + errors.push(`${artifactType} schema must define canonical property: ${field}`); + } + } +} + +function checkEnumFields(artifactType, contract, schema) { + for (const [field, expectedValues] of Object.entries(contract.enum_fields || {})) { + const property = schema.properties && schema.properties[field]; + if (!property) { + errors.push(`${artifactType} schema must define enum property: ${field}`); + continue; + } + + const actualValues = property.enum || []; + for (const value of expectedValues) { + if (!actualValues.includes(value)) { + errors.push(`${artifactType}.${field} schema enum missing value: ${value}`); + } + } + } +} + +function resolveRepoRoot(startDir) { + const candidates = [ + path.resolve(startDir, ".."), + path.resolve(startDir, "..", "..") + ]; + + for (const candidate of candidates) { + if (existsSync(path.join(candidate, ".agents", "schemas", "artifact-scope.json"))) { + return candidate; + } + } + + return path.resolve(startDir, ".."); +} + +function toRepoPath(filePath) { + return path.relative(repoRoot, filePath).split(path.sep).join("/"); +} diff --git a/.agents/scripts/check-closeout-learning-proposals.mjs b/.agents/scripts/check-closeout-learning-proposals.mjs new file mode 100644 index 0000000..9ac618b --- /dev/null +++ b/.agents/scripts/check-closeout-learning-proposals.mjs @@ -0,0 +1,23 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const errors = []; +const schema = readJson(path.join(repoRoot, ".agents", "schemas", "learning", "closeout-learning-proposal.schema.json")); +const skill = readText(path.join(repoRoot, ".agents", "skills", "closeout-skill", "SKILL.md")); +const runbook = readText(path.join(repoRoot, ".agents", "skills", "closeout-skill", "references", "runbook.md")); +const checklist = readText(path.join(repoRoot, ".agents", "skills", "closeout-skill", "templates", "closure-checklist.md")); +const template = readText(path.join(repoRoot, ".agents", "skills", "closeout-skill", "templates", "learning-proposal.md")); +for (const field of ["schema_version", "project", "task_ids", "proposal_type", "title", "rationale", "target_paths", "review_gate", "adoption_status"]) if (!schema.required?.includes(field)) errors.push(`proposal schema missing required field: ${field}`); +for (const type of ["rule", "skill", "schema", "fixture"]) if (!schema.properties?.proposal_type?.enum?.includes(type)) errors.push(`proposal schema missing proposal type: ${type}`); +if (!schema.properties?.review_gate?.enum?.includes("required-before-adoption")) errors.push("proposal schema must require review before adoption"); +if (schema.properties?.target_paths?.items?.not?.pattern !== "^/") errors.push("proposal target paths must reject absolute paths"); +for (const text of [skill, runbook, checklist, template]) { + if (!/review/i.test(text) || !/adoption/i.test(text)) errors.push("closeout learning workflow must mention review before adoption in skill assets"); +} +if (errors.length) { console.error("Closeout learning proposal check failed:"); for (const error of errors) console.error(`- ${error}`); process.exit(1); } +console.log("Closeout learning proposal workflow check passed."); +function readJson(filePath){ if(!existsSync(filePath)){ errors.push(`missing file: ${path.relative(repoRoot,filePath)}`); return {}; } return JSON.parse(readFileSync(filePath,"utf8")); } +function readText(filePath){ if(!existsSync(filePath)){ errors.push(`missing file: ${path.relative(repoRoot,filePath)}`); return ""; } return readFileSync(filePath,"utf8"); } +function resolveRepoRoot(startDir){ for(const c of [path.resolve(startDir,".."),path.resolve(startDir,"..","..")]) if(existsSync(path.join(c,".agents"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/check-context-audit.mjs b/.agents/scripts/check-context-audit.mjs new file mode 100644 index 0000000..723b155 --- /dev/null +++ b/.agents/scripts/check-context-audit.mjs @@ -0,0 +1,61 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const contextDir = readOption("--context") || path.join(repoRoot, ".project", "context"); +const requiredFiles = [ + "project-overview.md", + "project-brief.md", + "tech-context.md", + "project-structure.md", + "system-patterns.md", + "product-context.md", + "project-style-guide.md", + "progress.md", + "gui-testing.md" +]; +const commandFiles = new Map([ + ["project-style-guide.md", ["bash .agents/scripts/pm/validate.sh"]], + ["tech-context.md", ["validate.sh"]] +]); +const results = requiredFiles.map(auditFile); +const summary = results.reduce((acc, item) => { + acc[item.classification] = (acc[item.classification] || 0) + 1; + return acc; +}, {}); +const blocking = results.filter((item) => ["missing", "placeholder", "missing_required_commands"].includes(item.classification)); +const report = { schema_version: 1, context_dir: path.relative(repoRoot, contextDir) || ".", file_count: results.length, summary, results, blocking_count: blocking.length }; + +if (process.argv.includes("--json")) console.log(JSON.stringify(report, null, 2)); +else console.log(`Context audit scored ${results.length} file(s): ${Object.entries(summary).map(([k,v])=>`${k}=${v}`).join(", ")}.`); +if (blocking.length) process.exit(1); + +function auditFile(file) { + const filePath = path.join(contextDir, file); + if (!existsSync(filePath)) return { file, classification: "missing", score: 0, reasons: ["required context file is absent"] }; + const text = readFileSync(filePath, "utf8"); + const reasons = []; + if (isPlaceholder(text)) return { file, classification: "placeholder", score: 10, reasons: ["contains placeholder language or too little repo-specific content"] }; + const requiredCommands = commandFiles.get(file) || []; + const missingCommands = requiredCommands.filter((command) => !text.includes(command)); + if (missingCommands.length) return { file, classification: "missing_required_commands", score: 50, reasons: missingCommands.map((command)=>`missing command reference: ${command}`) }; + if (file === "gui-testing.md") return { file, classification: "not_applicable", score: 100, reasons: ["advisory-only GUI policy file"] }; + if (isStale(text)) reasons.push("frontmatter updated date is older than the current delivery cycle"); + return { file, classification: reasons.length ? "stale" : "real", score: reasons.length ? 70 : 100, reasons }; +} +function isPlaceholder(text) { + const compact = text.replace(/---[\s\S]*?---/, "").trim(); + if (compact.length < 80) return true; + if (/TODO|TBD/i.test(compact)) return true; + const repoSpecific = /Delano|\.agents|\.project|HANDBOOK|npm|CLI|runtime/i.test(compact); + return !repoSpecific && /Capture architecture|Document major repository boundaries/i.test(compact); +} +function isStale(text) { + const match = text.match(/^updated:\s*(\d{4}-\d{2}-\d{2})/m); + return Boolean(match && match[1] < "2026-04-29"); +} +function readOption(name) { const i = process.argv.indexOf(name); return i === -1 ? "" : process.argv[i + 1]; } +function resolveRepoRoot(startDir) { for (const c of [path.resolve(startDir,".."),path.resolve(startDir,"..","..")]) if (existsSync(path.join(c,".project"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/check-delivery-metric-events.mjs b/.agents/scripts/check-delivery-metric-events.mjs new file mode 100644 index 0000000..e4a656b --- /dev/null +++ b/.agents/scripts/check-delivery-metric-events.mjs @@ -0,0 +1,35 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const schemaPath = path.join(repoRoot, ".agents", "schemas", "metrics", "delivery-event.schema.json"); +const errors = []; +const schema = readJson(schemaPath); + +const requiredFields = ["schema_version", "event_id", "event_type", "project", "created_at", "source", "privacy", "summary"]; +const eventTypes = ["task-status-change", "validation-run", "sync-drift", "evidence-gap", "blocked-time", "closeout-learning"]; +for (const field of requiredFields) if (!schema.required?.includes(field)) errors.push(`delivery metric event schema must require ${field}`); +for (const eventType of eventTypes) if (!schema.properties?.event_type?.enum?.includes(eventType)) errors.push(`delivery metric event type missing ${eventType}`); +if (schema.properties?.privacy?.properties?.raw_text_allowed?.const !== false) errors.push("delivery metric events must disallow raw text by default"); +if (schema.properties?.privacy?.properties?.summary_only?.const !== true) errors.push("delivery metric events must be summary-only"); +if (!schema.properties?.summary?.minLength) errors.push("delivery metric events must include a non-empty privacy-safe summary"); + +if (errors.length) { + console.error("Delivery metric event contract check failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} +console.log(`Delivery metric event schema check passed for ${eventTypes.length} event type(s).`); +console.log(`Delivery metric event contract check passed for ${eventTypes.length} event type(s).`); + +function readJson(filePath) { + if (!existsSync(filePath)) { + errors.push(`missing file: ${path.relative(repoRoot, filePath)}`); + return {}; + } + return JSON.parse(readFileSync(filePath, "utf8")); +} +function resolveRepoRoot(startDir) { for (const c of [path.resolve(startDir,".."),path.resolve(startDir,"..","..")]) if (existsSync(path.join(c,".agents"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/check-delivery-metrics.mjs b/.agents/scripts/check-delivery-metrics.mjs new file mode 100644 index 0000000..2013c72 --- /dev/null +++ b/.agents/scripts/check-delivery-metrics.mjs @@ -0,0 +1,52 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const errors = []; +const schema = readJson(path.join(repoRoot, ".agents", "schemas", "metrics", "delivery-events.schema.json")); +const docs = readText(path.join(repoRoot, ".agents", "logs", "delivery-metrics.md")); + +for (const field of ["schema_version", "event_id", "event_type", "timestamp", "project", "actor", "summary"]) { + if (!schema.required?.includes(field)) errors.push(`delivery metric schema missing required field: ${field}`); +} +const eventTypes = schema.properties?.event_type?.enum || []; +for (const eventType of ["task_status_changed", "validation_run", "lease_acquired", "lease_released", "drift_report_generated", "repair_plan_created", "closeout_recorded"]) { + if (!eventTypes.includes(eventType)) errors.push(`delivery metric schema missing event type: ${eventType}`); + if (!docs.includes(eventType)) errors.push(`delivery metrics docs missing event type: ${eventType}`); +} +if (schema.properties?.summary?.properties?.privacy?.const !== "metadata-only") errors.push("delivery metric summaries must be metadata-only."); +const repoPathPattern = schema.properties?.evidence?.properties?.repo_paths?.items?.not?.pattern; +if (repoPathPattern !== "^/") errors.push("delivery metric evidence must reject absolute repo paths."); +for (const forbidden of ["prompt_raw", "prompt_redacted", "transcript", "customer_data"]) { + if (JSON.stringify(schema).includes(forbidden) || docs.includes(forbidden)) errors.push(`delivery metrics contract mentions forbidden raw field: ${forbidden}`); +} + +if (errors.length > 0) { + console.error("Delivery metric event check failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} +console.log(`Delivery metric event check passed for ${eventTypes.length} event type(s).`); + +function readJson(filePath) { + if (!existsSync(filePath)) { + errors.push(`missing file: ${path.relative(repoRoot, filePath)}`); + return {}; + } + return JSON.parse(readFileSync(filePath, "utf8")); +} +function readText(filePath) { + if (!existsSync(filePath)) { + errors.push(`missing file: ${path.relative(repoRoot, filePath)}`); + return ""; + } + return readFileSync(filePath, "utf8"); +} +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) if (existsSync(path.join(candidate, ".project")) && existsSync(path.join(candidate, ".agents"))) return candidate; + return path.resolve(startDir, ".."); +} diff --git a/.agents/scripts/check-evidence-map.mjs b/.agents/scripts/check-evidence-map.mjs new file mode 100644 index 0000000..291fdae --- /dev/null +++ b/.agents/scripts/check-evidence-map.mjs @@ -0,0 +1,143 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const contractPath = path.join(repoRoot, ".agents", "schemas", "evidence-map.json"); +const projectsRoot = path.join(repoRoot, ".project", "projects"); +const errors = []; +const warnings = []; + +const contract = readJson(contractPath, "evidence map contract"); +const strictSince = Date.parse(contract.strict_since || "2026-04-29T00:00:00Z"); +const proofRules = Array.isArray(contract.done_task_rules) ? contract.done_task_rules : []; +if (contract.schema_version !== 1) errors.push("evidence-map.json schema_version must be 1."); +for (const requiredRule of ["acceptance-criteria-checked", "evidence-log-present", "validation-proof"]) { + if (!proofRules.some((rule) => rule.id === requiredRule)) errors.push(`evidence map contract missing rule: ${requiredRule}`); +} + +for (const taskFile of listTaskFiles(projectsRoot)) { + const text = readFileSync(taskFile, "utf8"); + const frontmatter = parseFrontmatter(taskFile, text); + if (frontmatter.status !== "done") continue; + + const strict = isStrictTask(frontmatter, strictSince); + const taskErrors = validateDoneTask(taskFile, text, proofRules); + if (strict) errors.push(...taskErrors); + else warnings.push(...taskErrors); +} + +if (errors.length > 0) { + console.error("Evidence map check failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} + +if (warnings.length > 0) { + console.warn(`Evidence map check warnings: ${warnings.length} legacy evidence warning(s).`); +} +console.log("Evidence map check passed for done task acceptance criteria."); + +function validateDoneTask(taskFile, text, proofRules) { + const localErrors = []; + const acceptanceSection = section(text, "Acceptance Criteria"); + const evidenceSection = section(text, "Evidence Log"); + const evidenceText = evidenceSection || ""; + const fullEvidenceContext = `${evidenceText}\n${text}`; + const criteria = acceptanceSection.split("\n").filter((line) => /^- \[[ xX]\]/.test(line.trim())); + + for (const criterion of criteria) { + if (!/^- \[[xX]\]/.test(criterion.trim())) { + localErrors.push(`${toRepoPath(taskFile)} is done but has unchecked acceptance criterion: ${criterion.trim()}`); + } + } + + const implementationEvidence = evidenceSection + .split("\n") + .filter((line) => /^- \d{4}-\d{2}-\d{2}/.test(line.trim()) && !line.toLowerCase().includes("implementation evidence pending")); + if (implementationEvidence.length === 0) { + localErrors.push(`${toRepoPath(taskFile)} is done but lacks implementation evidence in Evidence Log.`); + } + + for (const rule of proofRules) { + if (!rule.acceptance_criterion_contains) continue; + const matchingCriterion = criteria.find((criterion) => criterion.toLowerCase().includes(rule.acceptance_criterion_contains.toLowerCase())); + if (!matchingCriterion) continue; + const proofTerms = Array.isArray(rule.proof_terms) ? rule.proof_terms : []; + if (proofTerms.length === 0) continue; + const hasProof = proofTerms.some((term) => fullEvidenceContext.toLowerCase().includes(term.toLowerCase())); + if (!hasProof) { + localErrors.push(`${toRepoPath(taskFile)} criterion lacks mapped evidence proof for rule ${rule.id}: ${matchingCriterion.trim()}`); + } + } + + return localErrors; +} + +function isStrictTask(frontmatter, strictSince) { + const updated = Date.parse(frontmatter.updated || ""); + if (!Number.isNaN(updated) && updated >= strictSince) return true; + const created = Date.parse(frontmatter.created || ""); + return !Number.isNaN(created) && created >= strictSince; +} + +function section(text, heading) { + const lines = text.split("\n"); + const start = lines.findIndex((line) => line.trim() === `## ${heading}`); + if (start === -1) return ""; + const collected = []; + for (const line of lines.slice(start + 1)) { + if (line.startsWith("## ")) break; + collected.push(line); + } + return collected.join("\n").trim(); +} + +function parseFrontmatter(filePath, text) { + const match = text.match(/^---\n([\s\S]*?)\n---\n/); + if (!match) { errors.push(`${toRepoPath(filePath)} is missing frontmatter.`); return {}; } + const result = {}; + for (const line of match[1].split("\n")) { + const index = line.indexOf(":"); + if (index === -1) continue; + result[line.slice(0, index).trim()] = line.slice(index + 1).trim(); + } + return result; +} + +function listTaskFiles(root) { + if (!existsSync(root)) return []; + const files = []; + for (const project of readdirSync(root, { withFileTypes: true })) { + if (!project.isDirectory()) continue; + const tasksDir = path.join(root, project.name, "tasks"); + if (!existsSync(tasksDir)) continue; + for (const task of readdirSync(tasksDir, { withFileTypes: true })) { + if (task.isFile() && task.name.endsWith(".md")) files.push(path.join(tasksDir, task.name)); + } + } + return files; +} + +function readJson(filePath, label) { + try { return JSON.parse(readFileSync(filePath, "utf8")); } + catch (error) { errors.push(`Could not read ${label} at ${toRepoPath(filePath)}: ${error.message}`); return {}; } +} + +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) { + if (existsSync(path.join(candidate, ".project", "projects")) && existsSync(path.join(candidate, ".agents"))) return candidate; + } + return path.resolve(startDir, ".."); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function toRepoPath(filePath) { + return path.relative(repoRoot, filePath).split(path.sep).join("/"); +} diff --git a/.agents/scripts/check-github-status-inspection.mjs b/.agents/scripts/check-github-status-inspection.mjs new file mode 100644 index 0000000..e791d5c --- /dev/null +++ b/.agents/scripts/check-github-status-inspection.mjs @@ -0,0 +1,98 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const errors = []; +const warnings = []; +const snapshotPath = path.join(repoRoot, ".agents", "fixtures", "github", "status-snapshot.json"); +const syncMap = readLocalSyncMap(); +const snapshot = readJson(snapshotPath, "GitHub status snapshot", { repositories: [] }); +const snapshotIndex = indexSnapshot(snapshot); + +if (snapshot.schema_version !== 1) errors.push("GitHub status snapshot schema_version must be 1."); + +for (const project of syncMap.projects || []) { + for (const task of project.tasks || []) { + inspectRef(project, task, "github_issue", "issue"); + inspectRef(project, task, "github_pr", "pull_request"); + } +} + +if (errors.length > 0) { + console.error("GitHub status inspection failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} +console.log(`GitHub status inspection passed with ${warnings.length} local-only warning(s).`); +if (process.argv.includes("--verbose")) for (const warning of warnings) console.warn(`Warning: ${warning}`); + +function inspectRef(project, task, field, kind) { + const value = task[field]; + if (!value) return; + const parsed = parseGitHubRef(value, project.github_repo); + if (!parsed) { + errors.push(`${project.slug}/${task.local_id} has invalid ${field}: ${value}`); + return; + } + if (parsed.kind !== kind) errors.push(`${project.slug}/${task.local_id} expected ${kind} but got ${parsed.kind}: ${value}`); + const key = `${parsed.owner}/${parsed.repo}#${parsed.number}:${parsed.kind}`; + const remote = snapshotIndex.get(key); + if (!remote) { + warnings.push(`${project.slug}/${task.local_id} ${field} ${key} has no mock snapshot; treated as local-only.`); + return; + } + if (!remote.state) errors.push(`${project.slug}/${task.local_id} ${field} ${key} snapshot lacks state.`); + if (kind === "pull_request" && remote.mergeable === "unknown") warnings.push(`${project.slug}/${task.local_id} PR ${key} has unknown mergeability.`); +} + +function parseGitHubRef(value, fallbackRepo) { + const raw = String(value || "").trim(); + if (!raw) return null; + const url = raw.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/([0-9]+)$/); + if (url) return { owner: url[1], repo: url[2], kind: url[3] === "pull" ? "pull_request" : "issue", number: Number(url[4]) }; + const short = raw.match(/^#?([0-9]+)$/); + if (short && fallbackRepo && fallbackRepo.includes("/")) { + const [owner, repo] = fallbackRepo.split("/"); + return { owner, repo, kind: "issue", number: Number(short[1]) }; + } + return null; +} + +function indexSnapshot(snapshot) { + const index = new Map(); + for (const repo of snapshot.repositories || []) { + const owner = repo.owner; + const name = repo.name; + for (const issue of repo.issues || []) index.set(`${owner}/${name}#${issue.number}:issue`, issue); + for (const pr of repo.pull_requests || []) index.set(`${owner}/${name}#${pr.number}:pull_request`, pr); + } + return index; +} + +function readLocalSyncMap() { + const result = spawnSync(process.execPath, [resolveScript("check-local-sync-map.mjs"), "--json"], { cwd: repoRoot, encoding: "utf8" }); + if (result.status !== 0) { + errors.push(`local sync map reader failed: ${result.stderr || result.stdout}`); + return { projects: [] }; + } + const parsed = JSON.parse(result.stdout); + return parsed.sync_map || parsed; +} +function resolveScript(name) { + const canonical = path.join(repoRoot, ".agents", "scripts", name); + if (existsSync(canonical)) return canonical; + return path.join(repoRoot, "scripts", name); +} +function readJson(filePath, label, fallback) { + try { return JSON.parse(readFileSync(filePath, "utf8")); } + catch (error) { errors.push(`Could not read ${label}: ${error.message}`); return fallback; } +} +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) if (existsSync(path.join(candidate, ".project")) && existsSync(path.join(candidate, ".agents"))) return candidate; + return path.resolve(startDir, ".."); +} diff --git a/.agents/scripts/check-github-sync.mjs b/.agents/scripts/check-github-sync.mjs new file mode 100644 index 0000000..7b40d4f --- /dev/null +++ b/.agents/scripts/check-github-sync.mjs @@ -0,0 +1,159 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { readLocalSyncMap } from "./read-local-sync-map.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const args = new Set(process.argv.slice(2)); +const jsonMode = args.has("--json"); +const allowNetwork = args.has("--fetch"); +const syncMapPath = readOption("--sync-map"); +const statePath = readOption("--github-state") || readOption("--fixture"); + +const syncMap = syncMapPath ? readJson(syncMapPath) : readLocalSyncMap(repoRoot); +const githubState = statePath ? readJson(statePath) : (allowNetwork ? fetchGithubState(syncMap) : { repositories: {}, source: "none" }); +const report = inspectGithubSync(syncMap, githubState, { fetched: allowNetwork && !statePath }); + +if (jsonMode) { + console.log(JSON.stringify(report, null, 2)); +} else { + console.log(`GitHub sync inspection checked ${report.summary.checked_refs} refs; ${report.summary.drift_count} drift item(s).`); + if (!allowNetwork && !statePath) console.log("No GitHub state supplied; use --github-state for deterministic dry-run inspection or --fetch to query gh."); + for (const drift of report.drift) console.log(`- ${drift.severity}: ${drift.task} ${drift.summary}`); +} + +if (report.errors.length > 0) { + if (!jsonMode) { + console.error("GitHub sync inspection failed:"); + for (const error of report.errors) console.error(`- ${error}`); + } + process.exit(1); +} + +export function inspectGithubSync(syncMap, githubState = {}, options = {}) { + const errors = []; + const inspections = []; + const drift = []; + for (const project of syncMap.projects || []) { + const repo = project.github_repo || githubState.default_repository || ""; + for (const task of project.tasks || []) { + for (const field of ["github_issue", "github_pr"]) { + const localRef = task[field]; + if (!localRef) continue; + const type = field === "github_pr" ? "pull_request" : "issue"; + const parsed = parseGithubRef(localRef, repo, type); + const taskKey = `${project.slug}:${task.local_id}`; + if (!parsed) { + errors.push(`${taskKey} has unsupported ${field}: ${localRef}`); + continue; + } + const external = lookupGithubState(githubState, parsed); + const inspection = { + task: taskKey, + field, + local_ref: localRef, + repository: parsed.repository, + number: parsed.number, + type, + external_state: external?.state || "unknown", + external_url: external?.url || parsed.url || "", + source: external?.source || githubState.source || (options.fetched ? "gh" : "fixture-or-none") + }; + inspections.push(inspection); + if (!external) { + drift.push({ + drift_type: "orphan-drift", + severity: "warning", + task: taskKey, + target: `${parsed.repository}#${parsed.number}`, + summary: `references ${field} without inspected GitHub state`, + evidence: { local_ref: localRef } + }); + } else if (external.kind && external.kind !== type) { + drift.push({ + drift_type: "mapping-drift", + severity: "error", + task: taskKey, + target: `${parsed.repository}#${parsed.number}`, + summary: `local ${field} points at inspected ${external.kind}`, + evidence: { local_ref: localRef, external_kind: external.kind } + }); + } + } + } + } + return { + schema_version: 1, + source: githubState.source || (options.fetched ? "gh" : "local-dry-run"), + summary: { checked_refs: inspections.length, drift_count: drift.length }, + inspections, + drift, + errors + }; +} + +function parseGithubRef(ref, projectRepo, type) { + const value = String(ref || "").trim(); + if (!value) return null; + const urlMatch = value.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/(issues|pull)\/(\d+)/); + if (urlMatch) return { repository: urlMatch[1], number: urlMatch[3], url: value }; + const shorthand = value.match(/^([^/\s]+\/[^#\s]+)#(\d+)$/); + if (shorthand) return { repository: shorthand[1], number: shorthand[2] }; + const number = value.match(/^#?(\d+)$/); + if (number && projectRepo) return { repository: projectRepo, number: number[1] }; + return null; +} + +function lookupGithubState(state, parsed) { + const repo = state.repositories?.[parsed.repository] || state.repos?.[parsed.repository]; + if (!repo) return null; + const pr = repo.pull_requests?.[parsed.number] || repo.prs?.[parsed.number]; + if (pr) return { ...pr, kind: "pull_request", source: state.source || "fixture" }; + const issue = repo.issues?.[parsed.number]; + if (issue) return { ...issue, kind: "issue", source: state.source || "fixture" }; + return null; +} + +function fetchGithubState(syncMap) { + const repositories = {}; + if (!commandExists("gh")) return { repositories, source: "gh-unavailable" }; + for (const project of syncMap.projects || []) { + const repo = project.github_repo; + if (!repo) continue; + repositories[repo] ||= { issues: {}, pull_requests: {} }; + for (const task of project.tasks || []) { + const refs = [task.github_issue, task.github_pr].filter(Boolean); + for (const ref of refs) { + const parsed = parseGithubRef(ref, repo); + if (!parsed) continue; + const result = spawnSync("gh", ["issue", "view", parsed.number, "--repo", parsed.repository, "--json", "number,state,url"], { encoding: "utf8" }); + if (result.status === 0) { + const issue = JSON.parse(result.stdout); + repositories[parsed.repository] ||= { issues: {}, pull_requests: {} }; + repositories[parsed.repository].issues[String(issue.number)] = { state: issue.state, url: issue.url }; + } + } + } + } + return { repositories, source: "gh" }; +} + +function readOption(name) { + const index = process.argv.indexOf(name); + return index === -1 ? "" : process.argv[index + 1]; +} +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} +function commandExists(name) { + const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [name] : ["-v", name], { shell: process.platform !== "win32", stdio: "ignore" }); + return result.status === 0; +} +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) if (existsSync(path.join(candidate, ".project")) && existsSync(path.join(candidate, ".agents"))) return candidate; + return path.resolve(startDir, ".."); +} diff --git a/.agents/scripts/check-handoff-summaries.mjs b/.agents/scripts/check-handoff-summaries.mjs new file mode 100644 index 0000000..6c5517d --- /dev/null +++ b/.agents/scripts/check-handoff-summaries.mjs @@ -0,0 +1,63 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const statePath = readOption("--state") || path.join(repoRoot, ".agents", "leases", "active-leases.json"); +const leaseManagerScript = resolveScript("lease-manager.mjs"); +const selfTest = process.argv.includes("--self-test") || !existsSync(statePath); +const errors = []; + +if (selfTest) runSelfTest(); +else validateState(statePath); + +if (process.argv.includes("--json")) console.log(JSON.stringify({ schema_version: 1, ok: errors.length === 0, error_count: errors.length, errors }, null, 2)); +else console.log(`Handoff summary check ${errors.length ? "failed" : "passed"} with ${errors.length} error(s).`); + +if (errors.length) { + if (!process.argv.includes("--json")) for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} + +function validateState(filePath) { + const state = JSON.parse(readFileSync(filePath, "utf8")); + for (const lease of state.leases || []) { + if (lease.status !== "released") continue; + validateSummary(lease.handoff_summary || "", `${lease.project}/${lease.task_id}`); + } +} + +function runSelfTest() { + const dir = path.join(os.tmpdir(), `delano-handoff-${process.pid}`); + const state = path.join(dir, "leases.json"); + mkdirSync(dir, { recursive: true }); + try { + const acquire = spawnSync(process.execPath, [leaseManagerScript, "acquire", "--state", state, "--owner", "handoff-test", "--project", "delano-multi-agent-execution", "--task", "T-006", "--zone", ".agents/scripts/lease-manager.mjs"], { cwd: repoRoot, encoding: "utf8" }); + if (acquire.status !== 0) { errors.push(`self-test acquire failed: ${acquire.stderr || acquire.stdout}`); return; } + const lease = JSON.parse(readFileSync(state, "utf8")).leases[0]; + const missing = spawnSync(process.execPath, [leaseManagerScript, "release", "--state", state, "--lease-id", lease.lease_id], { cwd: repoRoot, encoding: "utf8" }); + if (missing.status === 0) errors.push("release without --handoff should be rejected for active stream closeout"); + const summary = "Changed: validated handoff requirement\nEvidence: self-test release gate\nBlockers: none\nLease state: released\nNext safe action: continue"; + const release = spawnSync(process.execPath, [leaseManagerScript, "release", "--state", state, "--lease-id", lease.lease_id, "--handoff", summary], { cwd: repoRoot, encoding: "utf8" }); + if (release.status !== 0) errors.push(`self-test release with handoff failed: ${release.stderr || release.stdout}`); + validateState(state); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function validateSummary(summary, target) { + const required = ["Changed:", "Evidence:", "Blockers:", "Lease state:", "Next safe action:"]; + for (const heading of required) if (!summary.includes(heading)) errors.push(`${target} handoff_summary missing ${heading}`); +} +function resolveScript(name) { + const canonical = path.join(repoRoot, ".agents", "scripts", name); + if (existsSync(canonical)) return canonical; + return path.join(repoRoot, "scripts", name); +} +function readOption(name) { const i = process.argv.indexOf(name); return i === -1 ? "" : process.argv[i + 1]; } +function resolveRepoRoot(startDir) { for (const c of [path.resolve(startDir,".."), path.resolve(startDir,"..","..")]) if (existsSync(path.join(c,".agents"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/check-lease-conflicts.mjs b/.agents/scripts/check-lease-conflicts.mjs new file mode 100644 index 0000000..68e8104 --- /dev/null +++ b/.agents/scripts/check-lease-conflicts.mjs @@ -0,0 +1,24 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const statePath = readOption("--state") || path.join(repoRoot, ".agents", "leases", "active-leases.json"); +const requestedZones = readList("--zone"); +const requestedMode = readOption("--mode") || "shared"; +const state = readState(statePath); +const active = (state.leases || []).filter((lease) => lease.status === "active" && new Date(lease.expires_at).getTime() > Date.now()); +const conflicts = []; +for (const lease of active) { + const overlap = requestedZones.length ? lease.conflict_zones.filter((zone) => requestedZones.includes(zone)) : []; + if (!requestedZones.length) continue; + if (overlap.length && (lease.mode === "exclusive" || requestedMode === "exclusive")) conflicts.push({ lease_id: lease.lease_id, owner: lease.owner, zones: overlap, mode: lease.mode }); +} +if (process.argv.includes("--json")) console.log(JSON.stringify({ schema_version: 1, conflict_count: conflicts.length, conflicts }, null, 2)); +else console.log(`Lease conflict check found ${conflicts.length} conflict(s).`); +if (conflicts.length) process.exit(2); +function readState(filePath) { if (!existsSync(filePath)) return { schema_version: 1, leases: [] }; return JSON.parse(readFileSync(filePath, "utf8")); } +function readOption(name) { const i = process.argv.indexOf(name); return i === -1 ? "" : process.argv[i + 1]; } +function readList(name) { const out=[]; process.argv.forEach((arg,i)=>{ if(arg===name && process.argv[i+1]) out.push(process.argv[i+1]); }); return out; } +function resolveRepoRoot(startDir) { for (const c of [path.resolve(startDir,".."),path.resolve(startDir,"..","..")]) if (existsSync(path.join(c,".agents"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/check-lease-contracts.mjs b/.agents/scripts/check-lease-contracts.mjs new file mode 100644 index 0000000..1a35867 --- /dev/null +++ b/.agents/scripts/check-lease-contracts.mjs @@ -0,0 +1,17 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const schema = JSON.parse(readFileSync(path.join(repoRoot, ".agents", "schemas", "leases", "lease.schema.json"), "utf8")); +const errors = []; +for (const field of ["schema_version", "lease_id", "owner", "project", "task_id", "status", "mode", "conflict_zones", "acquired_at", "expires_at"]) { + if (!schema.required?.includes(field)) errors.push(`lease schema must require ${field}`); +} +for (const status of ["active", "released", "expired"]) if (!schema.properties?.status?.enum?.includes(status)) errors.push(`lease status missing ${status}`); +for (const mode of ["shared", "exclusive"]) if (!schema.properties?.mode?.enum?.includes(mode)) errors.push(`lease mode missing ${mode}`); +if (!schema.properties?.handoff_summary) errors.push("lease schema must reserve handoff_summary for release closeout"); +if (errors.length) { console.error("Lease contract check failed:"); for (const e of errors) console.error(`- ${e}`); process.exit(1); } +console.log("Lease contract check passed for lifecycle fields and modes."); +function resolveRepoRoot(startDir) { for (const c of [path.resolve(startDir,".."), path.resolve(startDir,"..","..")]) if (existsSync(path.join(c,".agents"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/check-linear-issue-inspection.mjs b/.agents/scripts/check-linear-issue-inspection.mjs new file mode 100644 index 0000000..4026b67 --- /dev/null +++ b/.agents/scripts/check-linear-issue-inspection.mjs @@ -0,0 +1,68 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const errors = []; +const warnings = []; +const snapshotPath = path.join(repoRoot, ".agents", "fixtures", "linear", "issue-snapshot.json"); +const syncMap = readLocalSyncMap(); +const snapshot = readJson(snapshotPath, "Linear issue snapshot", { issues: [] }); +const issueIndex = new Map((snapshot.issues || []).map((issue) => [issue.id, issue])); + +if (snapshot.schema_version !== 1) errors.push("Linear issue snapshot schema_version must be 1."); + +for (const project of syncMap.projects || []) { + for (const task of project.tasks || []) { + if (!task.linear_issue_id) continue; + if (!/^[-_A-Za-z0-9]+$/.test(task.linear_issue_id)) { + errors.push(`${project.slug}/${task.local_id} has invalid linear_issue_id: ${task.linear_issue_id}`); + continue; + } + const issue = issueIndex.get(task.linear_issue_id); + if (!issue) { + warnings.push(`${project.slug}/${task.local_id} Linear issue ${task.linear_issue_id} has no mock snapshot; treated as local-only.`); + continue; + } + if (!issue.state) errors.push(`${task.linear_issue_id} snapshot lacks state.`); + if (issue.project_id && project.linear_project_id && issue.project_id !== project.linear_project_id) errors.push(`${project.slug}/${task.local_id} Linear project drift: ${issue.project_id} != ${project.linear_project_id}`); + for (const dependency of issue.depends_on || []) { + if (!task.depends_on?.includes(dependency.local_id || dependency)) warnings.push(`${project.slug}/${task.local_id} remote dependency not present locally: ${dependency.local_id || dependency}`); + } + } +} + +if (errors.length > 0) { + console.error("Linear issue inspection failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} +console.log(`Linear issue inspection passed with ${warnings.length} local-only warning(s).`); +if (process.argv.includes("--verbose")) for (const warning of warnings) console.warn(`Warning: ${warning}`); + +function readLocalSyncMap() { + const result = spawnSync(process.execPath, [resolveScript("check-local-sync-map.mjs"), "--json"], { cwd: repoRoot, encoding: "utf8" }); + if (result.status !== 0) { + errors.push(`local sync map reader failed: ${result.stderr || result.stdout}`); + return { projects: [] }; + } + const parsed = JSON.parse(result.stdout); + return parsed.sync_map || parsed; +} +function resolveScript(name) { + const canonical = path.join(repoRoot, ".agents", "scripts", name); + if (existsSync(canonical)) return canonical; + return path.join(repoRoot, "scripts", name); +} +function readJson(filePath, label, fallback) { + try { return JSON.parse(readFileSync(filePath, "utf8")); } + catch (error) { errors.push(`Could not read ${label}: ${error.message}`); return fallback; } +} +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) if (existsSync(path.join(candidate, ".project")) && existsSync(path.join(candidate, ".agents"))) return candidate; + return path.resolve(startDir, ".."); +} diff --git a/.agents/scripts/check-local-sync-map.mjs b/.agents/scripts/check-local-sync-map.mjs new file mode 100644 index 0000000..bc6128b --- /dev/null +++ b/.agents/scripts/check-local-sync-map.mjs @@ -0,0 +1,151 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const errors = []; +const warnings = []; +const jsonMode = process.argv.includes("--json"); + +const syncMap = buildLocalSyncMap(repoRoot); +validateSyncMap(syncMap); + +if (jsonMode) { + console.log(JSON.stringify({ sync_map: syncMap, errors, warnings }, null, 2)); +} else if (errors.length === 0) { + const taskCount = syncMap.projects.reduce((sum, project) => sum + project.tasks.length, 0); + console.log(`Local sync map check passed for ${syncMap.projects.length} projects and ${taskCount} tasks.`); + if (warnings.length > 0) for (const warning of warnings) console.warn(`Warning: ${warning}`); +} + +if (errors.length > 0) { + if (!jsonMode) { + console.error("Local sync map check failed:"); + for (const error of errors) console.error(`- ${error}`); + } + process.exit(1); +} + +function buildLocalSyncMap(root) { + const projectsRoot = path.join(root, ".project", "projects"); + const linearMap = readJson(path.join(root, ".project", "registry", "linear-map.json"), "linear map", { projects: {}, tasks: {} }); + const projects = []; + + if (!existsSync(projectsRoot)) { + errors.push("Missing .project/projects directory."); + return { schema_version: 1, source: "local-project-contracts", projects }; + } + + for (const entry of readdirSync(projectsRoot, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + const projectDir = path.join(projectsRoot, entry.name); + const spec = parseFrontmatter(readText(path.join(projectDir, "spec.md"))); + const plan = parseFrontmatter(readText(path.join(projectDir, "plan.md"))); + const projectSlug = spec.slug || entry.name; + const registryProject = linearMap.projects?.[projectSlug] || {}; + const project = { + slug: projectSlug, + local_path: `.project/projects/${entry.name}`, + linear_project_id: firstNonEmpty(plan.linear_project_id, spec.linear_project_id, registryProject.linear_project_id, registryProject.id), + github_repo: firstNonEmpty(plan.github_repo, spec.github_repo, registryProject.github_repo), + tasks: [] + }; + + const tasksDir = path.join(projectDir, "tasks"); + if (existsSync(tasksDir)) { + for (const taskEntry of readdirSync(tasksDir, { withFileTypes: true })) { + if (!taskEntry.isFile() || !taskEntry.name.endsWith(".md")) continue; + const taskPath = path.join(tasksDir, taskEntry.name); + const fm = parseFrontmatter(readText(taskPath)); + if (!fm.id) { + warnings.push(`${project.local_path}/tasks/${taskEntry.name} has no task id and was skipped.`); + continue; + } + const registryTask = linearMap.tasks?.[`${projectSlug}:${fm.id}`] || linearMap.tasks?.[fm.id] || {}; + project.tasks.push({ + local_id: fm.id, + local_path: `${project.local_path}/tasks/${taskEntry.name}`, + status: fm.status || "unknown", + linear_issue_id: firstNonEmpty(fm.linear_issue_id, registryTask.linear_issue_id, registryTask.id), + github_issue: firstNonEmpty(fm.github_issue, registryTask.github_issue), + github_pr: firstNonEmpty(fm.github_pr, registryTask.github_pr), + depends_on: parseList(fm.depends_on || "[]") + }); + } + } + project.tasks.sort((a, b) => a.local_id.localeCompare(b.local_id)); + projects.push(project); + } + + projects.sort((a, b) => a.slug.localeCompare(b.slug)); + return { schema_version: 1, source: "local-project-contracts", projects }; +} + +function validateSyncMap(syncMap) { + if (syncMap.schema_version !== 1) errors.push("local sync map schema_version must be 1."); + if (!Array.isArray(syncMap.projects)) errors.push("local sync map projects must be an array."); + const seenProjects = new Set(); + for (const project of syncMap.projects || []) { + if (!project.slug || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(project.slug)) errors.push(`invalid project slug: ${project.slug || ""}`); + if (seenProjects.has(project.slug)) errors.push(`duplicate project slug: ${project.slug}`); + seenProjects.add(project.slug); + if (!project.local_path || !/^\.project\/projects\/[^/]+$/.test(project.local_path)) errors.push(`invalid local project path for ${project.slug}`); + const seenTasks = new Set(); + for (const task of project.tasks || []) { + const taskKey = `${project.slug}:${task.local_id}`; + if (!/^T-[0-9]{3}$/.test(task.local_id || "")) errors.push(`invalid task id in ${project.slug}: ${task.local_id || ""}`); + if (seenTasks.has(task.local_id)) errors.push(`duplicate task id in ${project.slug}: ${task.local_id}`); + seenTasks.add(task.local_id); + if (!task.local_path?.startsWith(`${project.local_path}/tasks/`)) errors.push(`invalid task path for ${taskKey}`); + if (!Array.isArray(task.depends_on)) errors.push(`depends_on must be normalized as an array for ${taskKey}`); + } + } +} + +function parseFrontmatter(text) { + const match = text.match(/^---\n([\s\S]*?)\n---\n/); + if (!match) return {}; + const result = {}; + for (const line of match[1].split("\n")) { + const index = line.indexOf(":"); + if (index === -1) continue; + result[line.slice(0, index).trim()] = line.slice(index + 1).trim(); + } + return result; +} + +function parseList(raw) { + const value = String(raw).trim(); + if (!value || value === "[]") return []; + if (value.startsWith("[") && value.endsWith("]")) { + return value.slice(1, -1).split(",").map((item) => item.trim().replace(/^[\"']|[\"']$/g, "")).filter(Boolean); + } + return [value.replace(/^[\"']|[\"']$/g, "")].filter(Boolean); +} + +function firstNonEmpty(...values) { + for (const value of values) { + if (typeof value === "string" && value.trim() !== "") return value.trim(); + } + return ""; +} + +function readText(filePath) { + try { return readFileSync(filePath, "utf8"); } + catch { return ""; } +} + +function readJson(filePath, label, fallback) { + try { return JSON.parse(readFileSync(filePath, "utf8")); } + catch (error) { warnings.push(`Could not read ${label}: ${error.message}`); return fallback; } +} + +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) { + if (existsSync(path.join(candidate, ".agents")) && existsSync(path.join(candidate, ".project"))) return candidate; + } + return path.resolve(startDir, ".."); +} diff --git a/.agents/scripts/check-log-safety.sh b/.agents/scripts/check-log-safety.sh new file mode 100644 index 0000000..df5ed7c --- /dev/null +++ b/.agents/scripts/check-log-safety.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +errors=0 + +require_file() { + local file="$1" + if [[ -f "$file" ]]; then + echo "✅ $file" + else + echo "❌ Missing log-safety file: $file" + errors=$((errors + 1)) + fi +} + +echo "Log safety check" +echo "================" + +require_file ".agents/common/log-safety.js" +require_file ".agents/hooks/user-prompt-logger.js" +require_file ".agents/logs/schema.md" + +if [[ -e .claude || -L .claude ]]; then + require_file ".claude/common/log-safety.js" +fi + +if grep -q 'prompt_hash' .agents/hooks/user-prompt-logger.js && grep -q 'DELANO_LOG_RAW_PROMPTS' .agents/hooks/user-prompt-logger.js; then + echo "✅ Prompt logger stores hash metadata by default and gates raw text" +else + echo "❌ Prompt logger must store prompt_hash and gate raw text behind DELANO_LOG_RAW_PROMPTS" + errors=$((errors + 1)) +fi + +if grep -q 'redactObject' .agents/hooks/post-tool-logger.js && grep -q 'redactObject' .agents/scripts/log-event.js; then + echo "✅ Change/event metadata passes through redaction helpers" +else + echo "❌ Change/event logging must redact metadata before write" + errors=$((errors + 1)) +fi + +raw_schema_matches="$(grep -RIn '"prompt"[[:space:]]*:[[:space:]]*"string"' .agents .claude 2>/dev/null || true)" +if [[ -n "$raw_schema_matches" ]]; then + echo "❌ Raw prompt schema still documented:" + echo "$raw_schema_matches" + errors=$((errors + 1)) +else + echo "✅ Raw prompt schema is not documented as default" +fi + +if grep -q 'echo .*\$root' .agents/hooks/bash-worktree-fix.sh; then + echo "❌ bash-worktree-fix.sh prints the absolute repository root" + errors=$((errors + 1)) +else + echo "✅ bash-worktree-fix.sh prints a placeholder instead of the absolute root" +fi + +if [[ $errors -gt 0 ]]; then + exit 1 +fi diff --git a/.agents/scripts/check-operating-modes.mjs b/.agents/scripts/check-operating-modes.mjs new file mode 100644 index 0000000..2d83c15 --- /dev/null +++ b/.agents/scripts/check-operating-modes.mjs @@ -0,0 +1,99 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const modesPath = path.join(repoRoot, ".agents", "schemas", "operating-modes.json"); +const rulePath = path.join(repoRoot, ".agents", "rules", "delivery-modes.md"); +const errors = []; + +const contract = readJson(modesPath, "operating modes contract"); +const modes = Array.isArray(contract.modes) ? contract.modes : []; +const expectedModes = [0, 1, 2, 3, 4]; +const expectedSlugs = ["patch", "scoped-change", "feature", "uncertain-feature", "multi-stream"]; + +if (contract.schema_version !== 1) { + errors.push("operating-modes.json schema_version must be 1."); +} + +if (modes.length !== expectedModes.length) { + errors.push(`operating-modes.json must define ${expectedModes.length} modes.`); +} + +const seenModes = new Set(); +const seenSlugs = new Set(); +for (const [index, expectedMode] of expectedModes.entries()) { + const mode = modes[index]; + if (!mode) continue; + + if (mode.mode !== expectedMode) { + errors.push(`mode index ${index} must be mode ${expectedMode}.`); + } + if (mode.slug !== expectedSlugs[index]) { + errors.push(`mode ${expectedMode} must use slug ${expectedSlugs[index]}.`); + } + if (seenModes.has(mode.mode)) { + errors.push(`duplicate operating mode: ${mode.mode}`); + } + seenModes.add(mode.mode); + if (seenSlugs.has(mode.slug)) { + errors.push(`duplicate operating mode slug: ${mode.slug}`); + } + seenSlugs.add(mode.slug); + + for (const field of ["name", "use_when"]) { + if (typeof mode[field] !== "string" || mode[field].trim() === "") { + errors.push(`mode ${expectedMode} must define non-empty ${field}.`); + } + } + if (!Array.isArray(mode.requires) || mode.requires.length === 0) { + errors.push(`mode ${expectedMode} must define at least one requirement.`); + } +} + +const doc = readText(rulePath, "delivery modes rule"); +for (const slug of expectedSlugs) { + if (!doc.includes(slug)) { + errors.push(`delivery-modes.md must document slug: ${slug}`); + } +} + +if (errors.length > 0) { + console.error("Operating modes check failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} + +console.log("Operating modes check passed for modes 0 through 4."); + +function readJson(filePath, label) { + try { + return JSON.parse(readFileSync(filePath, "utf8")); + } catch (error) { + errors.push(`Could not read ${label} at ${toRepoPath(filePath)}: ${error.message}`); + return {}; + } +} + +function readText(filePath, label) { + try { + return readFileSync(filePath, "utf8"); + } catch (error) { + errors.push(`Could not read ${label} at ${toRepoPath(filePath)}: ${error.message}`); + return ""; + } +} + +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) { + if (existsSync(path.join(candidate, ".agents", "schemas"))) return candidate; + } + return path.resolve(startDir, ".."); +} + +function toRepoPath(filePath) { + return path.relative(repoRoot, filePath).split(path.sep).join("/"); +} diff --git a/.agents/scripts/check-path-standards.sh b/.agents/scripts/check-path-standards.sh new file mode 100644 index 0000000..5c51bea --- /dev/null +++ b/.agents/scripts/check-path-standards.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +matches_file="$(mktemp)" +trap 'rm -f "$matches_file"' EXIT + +compat_paths=() +if [[ -e .claude || -L .claude ]]; then + compat_paths+=(.claude) +fi + +if find .project .agents "${compat_paths[@]}" \ + -type f \ + \( -name '*.md' -o -name '*.json' -o -name '*.yaml' -o -name '*.yml' \) \ + -not -path '.agents/logs/*' \ + -not -path '.claude/logs/*' \ + -print0 | xargs -0 grep -nE '(/home/[^[:space:]]+|/Users/[^[:space:]]+|/mnt/[A-Za-z]/[^[:space:]]+|[A-Za-z]:\\[^[:space:]]+)' > "$matches_file" 2>/dev/null; then + echo "Absolute path violations found:" + cat "$matches_file" + exit 1 +fi + +echo "Path standards check passed." diff --git a/.agents/scripts/check-skill-output-evals.mjs b/.agents/scripts/check-skill-output-evals.mjs new file mode 100644 index 0000000..409d9d5 --- /dev/null +++ b/.agents/scripts/check-skill-output-evals.mjs @@ -0,0 +1,13 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const root = path.join(repoRoot, ".agents", "eval-fixtures", "skill-output"); +const errors=[]; let checked=0; +for (const kind of ["valid","invalid"]) for (const dir of listDirs(path.join(root, kind))) { const fixture=JSON.parse(readFileSync(path.join(root, kind, dir, "output.json"), "utf8")); checked++; const valid=Array.isArray(fixture.evidence) && fixture.evidence.length > 0 && fixture.privacy === "metadata-only"; if (kind === "valid" && !valid) errors.push(`${dir} expected valid skill output`); if (kind === "invalid" && valid) errors.push(`${dir} expected invalid skill output`); } +if (checked < 2) errors.push("expected at least one valid and one invalid skill output fixture"); +if(errors.length){ console.error("Skill output eval check failed:"); errors.forEach(e=>console.error(`- ${e}`)); process.exit(1); } +console.log(`Skill output eval check passed for ${checked} fixture(s).`); +function listDirs(dir){ if(!existsSync(dir)) return []; return readdirSync(dir,{withFileTypes:true}).filter(d=>d.isDirectory()).map(d=>d.name); } +function resolveRepoRoot(startDir){ for(const c of [path.resolve(startDir,".."),path.resolve(startDir,"..","..")]) if(existsSync(path.join(c,".agents"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/check-status-transitions.mjs b/.agents/scripts/check-status-transitions.mjs new file mode 100644 index 0000000..54c8eb4 --- /dev/null +++ b/.agents/scripts/check-status-transitions.mjs @@ -0,0 +1,338 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const contractPath = path.join(repoRoot, ".agents", "schemas", "status-transitions.json"); +const args = process.argv.slice(2); +const projectsRoot = path.resolve(repoRoot, valueAfter(args, "--projects-root") || path.join(".project", "projects")); +const errors = []; + +const contract = readJson(contractPath, "status transition contract"); +if (contract.schema_version !== 1) { + errors.push("status-transitions.json schema_version must be 1."); +} +const rules = Array.isArray(contract.task_rules) ? contract.task_rules : []; +for (const requiredRule of [ + "ready-dependencies-done", + "blocked-owner-check-back", + "progressed-task-requires-active-project", + "closed-task-set-requires-closed-project", + "progressed-task-requires-active-workstream", + "closed-task-set-requires-closed-workstream" +]) { + if (!rules.some((rule) => rule.id === requiredRule)) { + errors.push(`status transition contract missing rule: ${requiredRule}`); + } +} + +const transitionRequest = parseTransitionArgs(args); +if (transitionRequest) { + validateTransitionRequest(transitionRequest); + finish(); +} + +for (const projectDir of listDirectories(projectsRoot)) { + const specPath = path.join(projectDir, "spec.md"); + const planPath = path.join(projectDir, "plan.md"); + const specFrontmatter = existsSync(specPath) ? parseFrontmatter(specPath) : null; + const planFrontmatter = existsSync(planPath) ? parseFrontmatter(planPath) : null; + const hasProjectLifecycle = Boolean(specFrontmatter || planFrontmatter); + const workstreams = collectWorkstreams(projectDir); + const workstreamSummaries = new Map(); + for (const [workstreamId, workstream] of workstreams.entries()) { + workstreamSummaries.set(workstreamId, { + workstream, + totalTaskCount: 0, + openTaskCount: 0 + }); + } + const tasksDir = path.join(projectDir, "tasks"); + if (!existsSync(tasksDir)) continue; + + const tasks = new Map(); + let totalTaskCount = 0; + let openTaskCount = 0; + let progressedTaskCount = 0; + for (const taskFile of listMarkdownFiles(tasksDir)) { + const frontmatter = parseFrontmatter(taskFile); + const id = frontmatter.id || path.basename(taskFile, ".md").split("-").slice(0, 2).join("-"); + const status = frontmatter.status || ""; + totalTaskCount += 1; + if (!isClosedTaskStatus(status)) openTaskCount += 1; + if (isProgressedTaskStatus(status)) progressedTaskCount += 1; + const taskWorkstream = frontmatter.workstream || ""; + if (taskWorkstream && workstreamSummaries.has(taskWorkstream)) { + const summary = workstreamSummaries.get(taskWorkstream); + summary.totalTaskCount += 1; + if (!isClosedTaskStatus(status)) summary.openTaskCount += 1; + } + tasks.set(id, { file: taskFile, frontmatter }); + } + + if (hasProjectLifecycle) { + validateProjectLifecycle({ + projectDir, + specStatus: specFrontmatter?.status || "", + planStatus: planFrontmatter?.status || "", + totalTaskCount, + openTaskCount, + progressedTaskCount + }); + } + + for (const [taskId, task] of tasks.entries()) { + const status = task.frontmatter.status || ""; + const dependencies = parseList(task.frontmatter.depends_on || "[]"); + const taskWorkstream = task.frontmatter.workstream || ""; + const workstream = taskWorkstream ? workstreams.get(taskWorkstream) : null; + + if (isProgressedTaskStatus(status)) { + if (!taskWorkstream) { + errors.push(`${toRepoPath(task.file)} has status ${status} but is missing workstream frontmatter; expected an existing workstream id.`); + } else if (!workstream) { + errors.push(`${toRepoPath(task.file)} has status ${status} but workstream ${taskWorkstream} does not exist; expected an existing workstream id.`); + } else { + validateTaskWorkstreamLifecycle({ task, workstream }); + } + } + + if (["ready", "in-progress", "done"].includes(status)) { + for (const dependencyId of dependencies) { + const dependency = tasks.get(dependencyId); + if (!dependency) continue; + const dependencyStatus = dependency.frontmatter.status || ""; + if (dependencyStatus !== "done") { + const message = `${toRepoPath(task.file)} has status ${status} but depends on unresolved ${dependencyId} (${dependencyStatus || "missing status"}).`; + errors.push(message); + } + } + } + + if (status === "blocked") { + for (const field of ["blocked_owner", "blocked_check_back"]) { + if (!task.frontmatter[field] || task.frontmatter[field].trim() === "") { + errors.push(`${toRepoPath(task.file)} is blocked but missing ${field}.`); + } + } + } + } + + for (const summary of workstreamSummaries.values()) { + validateWorkstreamLifecycle(summary); + } +} + +finish(); + +function parseTransitionArgs(args) { + if (!args.includes("--validate-transition")) return null; + const nextStatus = valueAfter(args, "--validate-transition"); + const dependencyStatuses = valueAfter(args, "--dependency-statuses") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const blockedOwner = valueAfter(args, "--blocked-owner"); + const blockedCheckBack = valueAfter(args, "--blocked-check-back"); + const specStatus = valueAfter(args, "--spec-status"); + const planStatus = valueAfter(args, "--plan-status"); + const workstreamStatus = valueAfter(args, "--workstream-status"); + return { nextStatus, dependencyStatuses, blockedOwner, blockedCheckBack, specStatus, planStatus, workstreamStatus }; +} + +function validateTransitionRequest(request) { + if (["ready", "in-progress", "done"].includes(request.nextStatus)) { + for (const dependencyStatus of request.dependencyStatuses) { + if (dependencyStatus !== "done") { + errors.push(`cannot transition to ${request.nextStatus} with unresolved dependency status: ${dependencyStatus}`); + } + } + } + + if (["in-progress", "done"].includes(request.nextStatus)) { + if (request.specStatus && !isActiveOrClosedSpecStatus(request.specStatus)) { + errors.push(`cannot transition to ${request.nextStatus} while spec status is ${request.specStatus}; expected active or complete`); + } + if (request.planStatus && !isActiveOrClosedPlanStatus(request.planStatus)) { + errors.push(`cannot transition to ${request.nextStatus} while plan status is ${request.planStatus}; expected active or done`); + } + if (request.nextStatus === "in-progress" && request.workstreamStatus && !isActiveWorkstreamStatus(request.workstreamStatus)) { + errors.push(`cannot transition to in-progress while workstream status is ${request.workstreamStatus}; expected active`); + } + if (request.nextStatus === "done" && request.workstreamStatus && !isActiveOrClosedWorkstreamStatus(request.workstreamStatus)) { + errors.push(`cannot transition to done while workstream status is ${request.workstreamStatus}; expected active or done`); + } + } + + if (request.nextStatus === "blocked") { + if (!request.blockedOwner) errors.push("cannot transition to blocked without blocked_owner"); + if (!request.blockedCheckBack) errors.push("cannot transition to blocked without blocked_check_back"); + } +} + +function validateProjectLifecycle(request) { + const projectPath = toRepoPath(request.projectDir); + if (request.progressedTaskCount > 0) { + if (!isActiveOrClosedSpecStatus(request.specStatus)) { + errors.push(`${projectPath} has ${request.progressedTaskCount} progressed task(s) but spec.md status is ${describeStatus(request.specStatus)}; expected active or complete before tasks can progress.`); + } + if (!isActiveOrClosedPlanStatus(request.planStatus)) { + errors.push(`${projectPath} has ${request.progressedTaskCount} progressed task(s) but plan.md status is ${describeStatus(request.planStatus)}; expected active or done before tasks can progress.`); + } + } + + if (request.totalTaskCount > 0 && request.openTaskCount === 0) { + if (!isClosedSpecStatus(request.specStatus)) { + errors.push(`${projectPath} has no open tasks but spec.md status is ${describeStatus(request.specStatus)}; expected complete or deferred.`); + } + if (!isClosedPlanStatus(request.planStatus)) { + errors.push(`${projectPath} has no open tasks but plan.md status is ${describeStatus(request.planStatus)}; expected done or deferred.`); + } + } +} + +function validateTaskWorkstreamLifecycle({ task, workstream }) { + const status = task.frontmatter.status || ""; + const workstreamStatus = workstream.frontmatter.status || ""; + if (status === "in-progress" && !isActiveWorkstreamStatus(workstreamStatus)) { + errors.push(`${toRepoPath(task.file)} has status in-progress but workstream ${workstream.id} status is ${describeStatus(workstreamStatus)}; expected active.`); + } + if (status === "done" && !isActiveOrClosedWorkstreamStatus(workstreamStatus)) { + errors.push(`${toRepoPath(task.file)} has status done but workstream ${workstream.id} status is ${describeStatus(workstreamStatus)}; expected active or done.`); + } +} + +function validateWorkstreamLifecycle({ workstream, totalTaskCount, openTaskCount }) { + const workstreamStatus = workstream.frontmatter.status || ""; + if (totalTaskCount > 0 && openTaskCount === 0 && !isClosedWorkstreamStatus(workstreamStatus)) { + errors.push(`${toRepoPath(workstream.file)} has no open tasks but status is ${describeStatus(workstreamStatus)}; expected done or deferred.`); + } +} + +function isProgressedTaskStatus(status) { + return ["in-progress", "done"].includes(status); +} + +function isClosedTaskStatus(status) { + return ["done", "deferred", "canceled"].includes(status); +} + +function isActiveOrClosedSpecStatus(status) { + return ["active", "complete"].includes(status); +} + +function isActiveOrClosedPlanStatus(status) { + return ["active", "done"].includes(status); +} + +function isClosedSpecStatus(status) { + return ["complete", "deferred"].includes(status); +} + +function isClosedPlanStatus(status) { + return ["done", "deferred"].includes(status); +} + +function isActiveWorkstreamStatus(status) { + return status === "active"; +} + +function isActiveOrClosedWorkstreamStatus(status) { + return ["active", "done"].includes(status); +} + +function isClosedWorkstreamStatus(status) { + return ["done", "deferred"].includes(status); +} + +function describeStatus(status) { + return status || "missing status"; +} + +function valueAfter(args, flag) { + const index = args.indexOf(flag); + if (index === -1 || index === args.length - 1) return ""; + return args[index + 1]; +} + +function finish() { + if (errors.length > 0) { + console.error("Status transition check failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); + } + + console.log("Status transition check passed for current project tasks."); + process.exit(0); +} + +function readJson(filePath, label) { + try { return JSON.parse(readFileSync(filePath, "utf8")); } + catch (error) { errors.push(`Could not read ${label} at ${toRepoPath(filePath)}: ${error.message}`); return {}; } +} + +function parseFrontmatter(filePath) { + const text = readFileSync(filePath, "utf8"); + const match = text.match(/^---\n([\s\S]*?)\n---\n/); + if (!match) { + errors.push(`${toRepoPath(filePath)} is missing frontmatter.`); + return {}; + } + const result = {}; + for (const line of match[1].split("\n")) { + const index = line.indexOf(":"); + if (index === -1) continue; + result[line.slice(0, index).trim()] = line.slice(index + 1).trim(); + } + return result; +} + +function parseList(raw) { + const value = raw.trim(); + if (!value || value === "[]") return []; + if (value.startsWith("[") && value.endsWith("]")) { + const inner = value.slice(1, -1).trim(); + if (!inner) return []; + return inner.split(",").map((item) => item.trim().replace(/^['\"]|['\"]$/g, "")).filter(Boolean); + } + return [value.replace(/^['\"]|['\"]$/g, "")].filter(Boolean); +} + +function listDirectories(root) { + if (!existsSync(root)) return []; + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(root, entry.name)); +} + +function listMarkdownFiles(root) { + if (!existsSync(root)) return []; + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) + .map((entry) => path.join(root, entry.name)); +} + +function collectWorkstreams(projectDir) { + const workstreamsDir = path.join(projectDir, "workstreams"); + const workstreams = new Map(); + for (const workstreamFile of listMarkdownFiles(workstreamsDir)) { + const frontmatter = parseFrontmatter(workstreamFile); + const id = frontmatter.id || path.basename(workstreamFile, ".md").match(/^(WS-[A-Za-z0-9]+)/)?.[1] || ""; + if (id) workstreams.set(id, { id, file: workstreamFile, frontmatter }); + } + return workstreams; +} + +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) { + if (existsSync(path.join(candidate, ".project", "projects")) && existsSync(path.join(candidate, ".agents"))) return candidate; + } + return path.resolve(startDir, ".."); +} + +function toRepoPath(filePath) { + return path.relative(repoRoot, filePath).split(path.sep).join("/"); +} diff --git a/.agents/scripts/check-strict-fixtures.mjs b/.agents/scripts/check-strict-fixtures.mjs new file mode 100644 index 0000000..001c264 --- /dev/null +++ b/.agents/scripts/check-strict-fixtures.mjs @@ -0,0 +1,140 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const fixturesRoot = path.join(repoRoot, ".agents", "validation-fixtures", "strict"); +const manifestPath = path.join(fixturesRoot, "manifest.json"); +const errors = []; + +const manifest = readJson(manifestPath, "strict fixture manifest"); +const fixtures = Array.isArray(manifest.fixtures) ? manifest.fixtures : []; +const requiredInvalidRules = new Set(["missing-evidence", "broken-dependencies", "stale-context", "path-leak", "invalid-transition"]); + +if (manifest.schema_version !== 1) errors.push("strict fixture manifest schema_version must be 1."); +if (!fixtures.some((fixture) => fixture.kind === "valid" && fixture.expected === "pass")) { + errors.push("strict fixtures must include at least one valid passing project."); +} + +for (const rule of requiredInvalidRules) { + if (!fixtures.some((fixture) => fixture.kind === "invalid" && fixture.expected_rule === rule)) { + errors.push(`strict fixtures missing invalid fixture for rule: ${rule}`); + } +} + +for (const fixture of fixtures) { + const fixturePath = path.join(fixturesRoot, fixture.path || ""); + if (!existsSync(fixturePath)) { + errors.push(`fixture path does not exist: ${fixture.path}`); + continue; + } + + const violations = validateFixture(fixturePath); + if (fixture.kind === "valid" && violations.length > 0) { + errors.push(`${fixture.name} expected pass but produced violations: ${violations.join(", ")}`); + } + if (fixture.kind === "invalid" && !violations.includes(fixture.expected_rule)) { + errors.push(`${fixture.name} expected rule ${fixture.expected_rule} but produced: ${violations.join(", ") || "none"}`); + } +} + +if (errors.length > 0) { + console.error("Strict fixture check failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} + +console.log(`Strict fixture check passed for ${fixtures.length} fixtures.`); + +function validateFixture(fixturePath) { + const violations = new Set(); + const markdownFiles = listMarkdownFiles(fixturePath); + const tasks = new Map(); + + for (const file of markdownFiles) { + const text = readFileSync(file, "utf8"); + if (/PATH_LEAK_TOKEN\(/.test(text) || /\/home\/[^\s)]+/.test(text) || /\/Users\/[^\s)]+/.test(text) || /\/mnt\/[A-Za-z]\/[^\s)]+/.test(text) || /[A-Z]:\\Users\\[^\s)]+/i.test(text)) violations.add("path-leak"); + const frontmatter = parseFrontmatter(text); + if (frontmatter.id && frontmatter.status) tasks.set(frontmatter.id, { file, text, frontmatter }); + if (frontmatter.review_by && Date.parse(frontmatter.review_by) < Date.parse("2026-04-29T00:00:00Z")) violations.add("stale-context"); + } + + for (const task of tasks.values()) { + const fm = task.frontmatter; + const dependencies = parseList(fm.depends_on || "[]"); + if (["ready", "in-progress", "done"].includes(fm.status)) { + for (const dependencyId of dependencies) { + const dependency = tasks.get(dependencyId); + if (dependency && dependency.frontmatter.status !== "done") violations.add("broken-dependencies"); + } + } + if (fm.status === "blocked" && (!fm.blocked_owner || !fm.blocked_check_back)) violations.add("invalid-transition"); + if (fm.status === "done") { + const acceptance = section(task.text, "Acceptance Criteria"); + const evidence = section(task.text, "Evidence Log"); + if (acceptance.includes("- [ ]") || !/^- \d{4}-\d{2}-\d{2}.*(validation passed|Validation:|passed:)/im.test(evidence)) { + violations.add("missing-evidence"); + } + } + } + + return [...violations].sort(); +} + +function parseFrontmatter(text) { + const match = text.match(/^---\n([\s\S]*?)\n---\n/); + if (!match) return {}; + const result = {}; + for (const line of match[1].split("\n")) { + const index = line.indexOf(":"); + if (index === -1) continue; + result[line.slice(0, index).trim()] = line.slice(index + 1).trim(); + } + return result; +} + +function parseList(raw) { + const value = raw.trim(); + if (!value || value === "[]") return []; + if (value.startsWith("[") && value.endsWith("]")) { + return value.slice(1, -1).split(",").map((item) => item.trim().replace(/^['\"]|['\"]$/g, "")).filter(Boolean); + } + return [value.replace(/^['\"]|['\"]$/g, "")].filter(Boolean); +} + +function section(text, heading) { + const lines = text.split("\n"); + const start = lines.findIndex((line) => line.trim() === `## ${heading}`); + if (start === -1) return ""; + const collected = []; + for (const line of lines.slice(start + 1)) { + if (line.startsWith("## ")) break; + collected.push(line); + } + return collected.join("\n").trim(); +} + +function listMarkdownFiles(root) { + const files = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const fullPath = path.join(root, entry.name); + if (entry.isDirectory()) files.push(...listMarkdownFiles(fullPath)); + if (entry.isFile() && entry.name.endsWith(".md")) files.push(fullPath); + } + return files; +} + +function readJson(filePath, label) { + try { return JSON.parse(readFileSync(filePath, "utf8")); } + catch (error) { errors.push(`Could not read ${label}: ${error.message}`); return {}; } +} + +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) { + if (existsSync(path.join(candidate, ".agents")) && existsSync(path.join(candidate, ".project"))) return candidate; + } + return path.resolve(startDir, ".."); +} diff --git a/.agents/scripts/check-sync-schemas.mjs b/.agents/scripts/check-sync-schemas.mjs new file mode 100644 index 0000000..47f1006 --- /dev/null +++ b/.agents/scripts/check-sync-schemas.mjs @@ -0,0 +1,52 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const errors = []; +const taxonomyPath = path.join(repoRoot, ".agents", "schemas", "sync", "drift-taxonomy.json"); +const syncMapSchemaPath = path.join(repoRoot, ".agents", "schemas", "sync", "sync-map.schema.json"); +const taxonomy = readJson(taxonomyPath, "drift taxonomy"); +const syncMapSchema = readJson(syncMapSchemaPath, "sync map schema"); + +const requiredDrifts = ["mapping-drift", "status-drift", "dependency-drift", "orphan-drift", "repair-recommendation"]; +if (taxonomy.schema_version !== 1) errors.push("drift taxonomy schema_version must be 1."); +const driftTypes = Array.isArray(taxonomy.drift_types) ? taxonomy.drift_types : []; +for (const id of requiredDrifts) { + const drift = driftTypes.find((entry) => entry.id === id); + if (!drift) { + errors.push(`drift taxonomy missing type: ${id}`); + continue; + } + if (!drift.description || !Array.isArray(drift.severity) || drift.severity.length === 0 || !drift.repair_posture) { + errors.push(`drift type ${id} must define description, severity, and repair_posture.`); + } +} + +if (syncMapSchema.type !== "object") errors.push("sync map schema must be an object schema."); +for (const field of ["schema_version", "projects"]) { + if (!syncMapSchema.required?.includes(field)) errors.push(`sync map schema must require ${field}.`); +} +const projectProperties = syncMapSchema.properties?.projects?.items?.properties || {}; +for (const field of ["slug", "local_path", "linear_project_id", "github_repo", "tasks"]) { + if (!projectProperties[field]) errors.push(`sync map project schema missing ${field}.`); +} + +if (errors.length > 0) { + console.error("Sync schema check failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} +console.log("Sync schema check passed for drift taxonomy and sync map schema."); + +function readJson(filePath, label) { + try { return JSON.parse(readFileSync(filePath, "utf8")); } + catch (error) { errors.push(`Could not read ${label}: ${error.message}`); return {}; } +} +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) if (existsSync(path.join(candidate, ".agents"))) return candidate; + return path.resolve(startDir, ".."); +} diff --git a/.agents/scripts/check-text-safety.mjs b/.agents/scripts/check-text-safety.mjs new file mode 100644 index 0000000..f059acb --- /dev/null +++ b/.agents/scripts/check-text-safety.mjs @@ -0,0 +1,158 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, "..", ".."); + +const bidiControls = new Map([ + [0x200E, "LEFT-TO-RIGHT MARK"], + [0x200F, "RIGHT-TO-LEFT MARK"], + [0x202A, "LEFT-TO-RIGHT EMBEDDING"], + [0x202B, "RIGHT-TO-LEFT EMBEDDING"], + [0x202C, "POP DIRECTIONAL FORMATTING"], + [0x202D, "LEFT-TO-RIGHT OVERRIDE"], + [0x202E, "RIGHT-TO-LEFT OVERRIDE"], + [0x2066, "LEFT-TO-RIGHT ISOLATE"], + [0x2067, "RIGHT-TO-LEFT ISOLATE"], + [0x2068, "FIRST STRONG ISOLATE"], + [0x2069, "POP DIRECTIONAL ISOLATE"] +]); + +const binaryExtensions = new Set([ + ".bmp", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".pdf", + ".png", + ".tgz", + ".ttf", + ".webp", + ".woff", + ".woff2", + ".zip" +]); + +const args = process.argv.slice(2); +const files = resolveFiles(args); +const findings = []; + +for (const file of files) { + const absolutePath = path.resolve(repoRoot, file); + if (!existsSync(absolutePath) || !statSync(absolutePath).isFile()) { + continue; + } + + if (binaryExtensions.has(path.extname(absolutePath).toLowerCase())) { + continue; + } + + const buffer = readFileSync(absolutePath); + if (isProbablyBinary(buffer)) { + continue; + } + + inspectText(absolutePath, buffer.toString("utf8")); +} + +if (findings.length > 0) { + console.error("Text safety check failed:"); + for (const finding of findings) { + console.error(`- ${finding.file}:${finding.line}:${finding.column} contains ${finding.code} (${finding.name})`); + } + process.exit(1); +} + +console.log(`Text safety check passed for ${files.length} tracked file(s).`); + +function resolveFiles(rawArgs) { + if (rawArgs.length === 0) { + return gitTrackedFiles(); + } + + const selectedFiles = []; + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === "--file") { + const value = rawArgs[index + 1]; + if (!value) { + throw new Error("--file requires a path"); + } + selectedFiles.push(value); + index += 1; + } else { + selectedFiles.push(arg); + } + } + + return selectedFiles; +} + +function gitTrackedFiles() { + const output = execFileSync("git", ["ls-files", "-z"], { + cwd: repoRoot, + encoding: "buffer" + }); + + return output + .toString("utf8") + .split("\0") + .filter(Boolean); +} + +function isProbablyBinary(buffer) { + const sampleLength = Math.min(buffer.length, 8000); + for (let index = 0; index < sampleLength; index += 1) { + if (buffer[index] === 0) { + return true; + } + } + return false; +} + +function inspectText(absolutePath, text) { + for (let index = 0; index < text.length; index += 1) { + const codePoint = text.codePointAt(index); + if (!bidiControls.has(codePoint)) { + continue; + } + + const position = locate(text, index); + findings.push({ + file: displayPath(absolutePath), + line: position.line, + column: position.column, + code: `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`, + name: bidiControls.get(codePoint) + }); + } +} + +function locate(text, index) { + let line = 1; + let lineStart = 0; + + for (let cursor = 0; cursor < index; cursor += 1) { + if (text[cursor] === "\n") { + line += 1; + lineStart = cursor + 1; + } + } + + return { + line, + column: index - lineStart + 1 + }; +} + +function displayPath(absolutePath) { + const relativePath = path.relative(repoRoot, absolutePath).replace(/\\/g, "/"); + if (!path.isAbsolute(relativePath) && !relativePath.startsWith("..") && relativePath !== "") { + return relativePath; + } + return path.basename(absolutePath); +} diff --git a/.agents/scripts/check-worktree-health.mjs b/.agents/scripts/check-worktree-health.mjs new file mode 100644 index 0000000..45321c2 --- /dev/null +++ b/.agents/scripts/check-worktree-health.mjs @@ -0,0 +1,100 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +const riskySharedPrefixes = [ + ".agents/scripts/", + ".claude/scripts/", + "scripts/", + "package.json", + "assets/install-manifest.json", + "assets/payload/" +]; + +const status = git(["status", "--porcelain=v1"]); +const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]); +const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]); +const worktreeList = git(["worktree", "list", "--porcelain"]); +const worktreePrune = git(["worktree", "prune", "--dry-run", "--verbose"]); + +const issues = []; +const warnings = []; +if (status.status !== 0) issues.push("git status failed"); +const branchName = branch.stdout.trim(); +if (branch.status !== 0 || !branchName) issues.push("branch could not be resolved"); +else if (branchName === "HEAD") warnings.push("branch is detached"); +if (worktreeList.status !== 0) issues.push("git worktree list failed"); +if (worktreePrune.status !== 0) warnings.push("git worktree prune dry-run failed"); + +const dirtyFiles = parseStatus(status.stdout); +const riskySharedFiles = dirtyFiles.filter((file) => riskySharedPrefixes.some((prefix) => file.path === prefix || file.path.startsWith(prefix))); +const worktrees = parseWorktrees(worktreeList.stdout); +const staleWorktrees = parseStaleWorktrees(worktreePrune.stdout); + +for (const file of riskySharedFiles) warnings.push(`risky shared file is dirty: ${file.path}`); +for (const stale of staleWorktrees) warnings.push(`stale worktree candidate: ${stale}`); + +const result = { + schema_version: 1, + branch: branchName || "unknown", + upstream: upstream.status === 0 ? upstream.stdout.trim() : "", + dirty: dirtyFiles.length > 0, + dirty_files: dirtyFiles, + risky_shared_files: riskySharedFiles, + worktrees, + stale_worktrees: staleWorktrees, + issues, + warnings +}; + +if (process.argv.includes("--json")) { + console.log(JSON.stringify(result, null, 2)); +} else { + console.log(`Worktree health: ${issues.length ? "issues" : "ok"}; dirty=${result.dirty}; branch=${result.branch}; risky=${riskySharedFiles.length}; stale=${staleWorktrees.length}.`); +} +if (issues.length) process.exit(1); + +function git(args) { + return spawnSync("git", args, { encoding: "utf8" }); +} +function parseStatus(text) { + return text.split("\n").filter(Boolean).map((line) => { + const statusCode = line.slice(0, 2); + const rawPath = line.slice(3).trim(); + const filePath = rawPath.includes(" -> ") ? rawPath.split(" -> ").pop() : rawPath; + return { status: statusCode.trim() || "??", path: normalizePath(filePath) }; + }); +} +function parseWorktrees(text) { + const entries = []; + let current = null; + for (const line of text.split("\n")) { + if (!line.trim()) { + if (current) entries.push(current); + current = null; + continue; + } + const [key, ...rest] = line.split(" "); + const value = rest.join(" "); + if (key === "worktree") current = { path: safeWorktreePath(value), branch: "", head: "", bare: false, detached: false }; + else if (!current) continue; + else if (key === "HEAD") current.head = value; + else if (key === "branch") current.branch = value.replace(/^refs\/heads\//, ""); + else if (key === "bare") current.bare = true; + else if (key === "detached") current.detached = true; + } + if (current) entries.push(current); + return entries; +} +function parseStaleWorktrees(text) { + return text.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => normalizePath(line.replace(/^Removing worktrees\//, "worktrees/"))); +} +function safeWorktreePath(value) { + const absolute = path.resolve(String(value || "")); + const cwd = path.resolve(process.cwd()); + if (absolute === cwd) return "."; + if (absolute.startsWith(`${cwd}${path.sep}`)) return normalizePath(path.relative(cwd, absolute)); + return `external:${path.basename(absolute)}`; +} +function normalizePath(value) { + return String(value || "").replaceAll(path.sep, "/"); +} diff --git a/.agents/scripts/fix-path-standards.sh b/.agents/scripts/fix-path-standards.sh new file mode 100644 index 0000000..d1782c5 --- /dev/null +++ b/.agents/scripts/fix-path-standards.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " + echo "Replaces absolute user paths with in the provided files." + exit 1 +fi + +for file in "$@"; do + [[ -f "$file" ]] || { echo "Skip missing file: $file"; continue; } + perl -0777 -i -pe 's#/home/[^\s)]+##g; s#/Users/[^\s)]+##g; s#/mnt/[A-Za-z]/[^\s)]+##g; s#[A-Za-z]:\\[^\s)]+##g' "$file" + echo "Normalized paths in: $file" +done diff --git a/.agents/scripts/git-sparse-download.sh b/.agents/scripts/git-sparse-download.sh new file mode 100644 index 0000000..68ee755 --- /dev/null +++ b/.agents/scripts/git-sparse-download.sh @@ -0,0 +1,162 @@ +#!/bin/bash + +# Git Sparse Checkout - Download specific directories from GitHub repositories +# Usage: ./git-sparse-download.sh [target_name] +# Example: ./git-sparse-download.sh https://github.com/google/guava guava-gwt +# Example: ./git-sparse-download.sh https://github.com/ComposioHQ/awesome-claude-skills skill-creator skills/skill-creator + +set -e + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_error() { echo -e "${RED}❌ $1${NC}"; } +print_success() { echo -e "${GREEN}✅ $1${NC}"; } +print_info() { echo -e "${YELLOW}ℹ️ $1${NC}"; } + +# Check arguments +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + echo "Usage: $0 [target_name]" + echo "" + echo "Arguments:" + echo " repo_url - GitHub repository URL (e.g., https://github.com/user/repo)" + echo " directory_path - Path to the directory in the repo (e.g., src/components)" + echo " target_name - Optional: Name for the downloaded directory (defaults to directory name)" + echo "" + echo "Examples:" + echo " $0 https://github.com/google/guava guava-gwt" + echo " $0 https://github.com/ComposioHQ/awesome-claude-skills skill-creator skills/my-skill" + exit 1 +fi + +REPO_URL=$1 +DIR_PATH=$2 +TARGET_NAME=${3:-$(basename "$DIR_PATH")} + +# Validate repo URL +if [[ ! "$REPO_URL" =~ ^https?://github\.com/.*$ ]]; then + print_error "Invalid GitHub URL. URL must start with https://github.com/" + exit 1 +fi + +# Clean up directory path (remove leading/trailing slashes) +DIR_PATH=$(echo "$DIR_PATH" | sed 's|^/||;s|/$||') + +print_info "Repository: $REPO_URL" +print_info "Directory: $DIR_PATH" +print_info "Target: $TARGET_NAME" + +# Create a temporary directory for the operation +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +print_info "Initializing sparse checkout..." + +# Navigate to temp directory +cd "$TEMP_DIR" || exit 1 + +# Initialize empty git repo +git init -q 2>/dev/null || { + print_error "Failed to initialize git repository" + exit 1 +} + +# Add remote +git remote add origin "$REPO_URL" 2>/dev/null || { + print_error "Failed to add remote repository" + exit 1 +} + +# Configure sparse checkout +git config core.sparseCheckout true + +# Handle both old and new sparse-checkout patterns +# Try new way first (Git 2.25+), fall back to old way +if command -v git sparse-checkout >/dev/null 2>&1 && git sparse-checkout init --cone 2>/dev/null; then + # New sparse-checkout command available + git sparse-checkout set "$DIR_PATH" 2>/dev/null || echo "$DIR_PATH" >> .git/info/sparse-checkout +else + # Fall back to old method + echo "$DIR_PATH/*" >> .git/info/sparse-checkout + echo "$DIR_PATH/**" >> .git/info/sparse-checkout +fi + +# Attempt to fetch and checkout +print_info "Downloading $DIR_PATH from repository..." + +# Determine the default branch +DEFAULT_BRANCH=$(git ls-remote --symref origin HEAD 2>/dev/null | grep "ref:" | sed 's/.*refs\/heads\///' | sed 's/[[:space:]].*$//') +if [ -z "$DEFAULT_BRANCH" ]; then + # Try common branch names + for branch in main master; do + if git ls-remote --heads origin $branch 2>/dev/null | grep -q $branch; then + DEFAULT_BRANCH=$branch + break + fi + done +fi + +# If we still don't have a branch, default to main +DEFAULT_BRANCH=${DEFAULT_BRANCH:-main} + +print_info "Using branch: $DEFAULT_BRANCH" + +# Fetch with depth 1 for speed +if ! git fetch --depth=1 origin "$DEFAULT_BRANCH" 2>/dev/null; then + print_error "Failed to fetch from repository. Please check the URL and your internet connection." + exit 1 +fi + +# Checkout the fetched branch +if ! git checkout -f "origin/$DEFAULT_BRANCH" 2>/dev/null; then + print_error "Failed to checkout repository content" + exit 1 +fi + +# Check if the directory exists +if [ ! -d "$DIR_PATH" ]; then + print_error "Directory '$DIR_PATH' not found in repository" + print_info "Available directories in root:" + ls -d */ 2>/dev/null | head -10 || echo " (none found)" + exit 1 +fi + +# Move the downloaded folder to the user's original location +print_info "Moving files to current directory..." + +# Ensure we're back in the original directory +cd "$OLDPWD" || exit 1 + +# Check if target already exists +if [ -e "$TARGET_NAME" ]; then + print_error "Target '$TARGET_NAME' already exists in current directory" + echo "Please remove it first or choose a different target name" + exit 1 +fi + +# Move the directory +if mv "$TEMP_DIR/$DIR_PATH" "$TARGET_NAME" 2>/dev/null; then + print_success "Successfully downloaded '$DIR_PATH' as '$TARGET_NAME'" + + # Show what was downloaded + echo "" + echo "Downloaded structure:" + if command -v tree >/dev/null 2>&1; then + tree -L 2 "$TARGET_NAME" 2>/dev/null | head -20 + else + ls -la "$TARGET_NAME" 2>/dev/null | head -10 + fi +else + print_error "Failed to move directory to target location" + exit 1 +fi + +# Additional info +echo "" +print_info "To use this as a Delano skill, copy it to .agents/skills/" +print_info "Compatibility mirror: .claude/skills/ when present" +print_info "Example: mv $TARGET_NAME .agents/skills/" diff --git a/.agents/scripts/inspect-github-sync.mjs b/.agents/scripts/inspect-github-sync.mjs new file mode 100644 index 0000000..69e27a9 --- /dev/null +++ b/.agents/scripts/inspect-github-sync.mjs @@ -0,0 +1,108 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readLocalSyncMap } from "./read-local-sync-map.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const jsonMode = process.argv.includes("--json"); +const errors = []; +const report = inspectGithubSync(repoRoot); + +if (jsonMode) { + console.log(JSON.stringify(report, null, 2)); +} else { + console.log(`GitHub sync inspection passed for ${report.projects.length} projects, ${report.summary.issue_refs} issue refs, and ${report.summary.pr_refs} PR refs.`); + for (const drift of report.drift) console.log(`- ${drift.severity}: ${drift.summary}`); +} + +if (errors.length > 0) { + if (!jsonMode) { + console.error("GitHub sync inspection failed:"); + for (const error of errors) console.error(`- ${error}`); + } + process.exit(1); +} + +export function inspectGithubSync(root = repoRoot) { + const syncMap = readLocalSyncMap(root); + const fallbackRepo = normalizeGitHubRepo(readGitRemote(root)); + const projects = []; + const drift = []; + let issueRefs = 0; + let prRefs = 0; + + for (const project of syncMap.projects) { + const githubRepo = normalizeGitHubRepo(project.github_repo) || fallbackRepo || ""; + const inspectedTasks = []; + for (const task of project.tasks) { + const issue = normalizeGitHubRef(task.github_issue, githubRepo, "issue"); + const pr = normalizeGitHubRef(task.github_pr, githubRepo, "pull_request"); + if (issue) issueRefs += 1; + if (pr) prRefs += 1; + if ((task.github_issue || task.github_pr) && !githubRepo) { + drift.push({ + drift_type: "mapping-drift", + severity: "warning", + target: `${project.slug}/${task.local_id}`, + summary: "Task has GitHub references but no project github_repo or origin GitHub remote.", + proposed_action: "Add github_repo to the sync registry or project contract before remote inspection.", + apply_posture: "dry-run-plan-first" + }); + } + inspectedTasks.push({ + local_id: task.local_id, + status: task.status, + issue, + pull_request: pr + }); + } + projects.push({ slug: project.slug, github_repo: githubRepo, tasks: inspectedTasks }); + } + + return { + schema_version: 1, + mode: "local-dry-run", + source: "local-task-metadata-and-git-remote", + summary: { issue_refs: issueRefs, pr_refs: prRefs, drift_count: drift.length }, + projects, + drift + }; +} + +function normalizeGitHubRef(value, fallbackRepo, kind) { + const raw = String(value || "").trim(); + if (!raw) return null; + const numberMatch = raw.match(/^#?([0-9]+)$/); + if (numberMatch) return { kind, repo: fallbackRepo || "", number: Number(numberMatch[1]), url: fallbackRepo ? `https://github.com/${fallbackRepo}/${kind === "pull_request" ? "pull" : "issues"}/${numberMatch[1]}` : "" }; + const urlMatch = raw.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/(issues|pull)\/([0-9]+)\/?$/); + if (urlMatch) return { kind: urlMatch[2] === "pull" ? "pull_request" : "issue", repo: normalizeGitHubRepo(urlMatch[1]), number: Number(urlMatch[3]), url: raw.replace(/\/$/, "") }; + errors.push(`Invalid GitHub ${kind} reference: ${raw}`); + return null; +} + +function normalizeGitHubRepo(value) { + const raw = String(value || "").trim(); + if (!raw) return ""; + const ssh = raw.match(/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/); + if (ssh) return ssh[1].replace(/\.git$/, ""); + const https = raw.match(/^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/); + if (https) return https[1].replace(/\.git$/, ""); + if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(raw)) return raw.replace(/\.git$/, ""); + return ""; +} + +function readGitRemote(root) { + const configPath = path.join(root, ".git", "config"); + if (!existsSync(configPath)) return ""; + const text = readFileSync(configPath, "utf8"); + const match = text.match(/\[remote "origin"\][\s\S]*?url = (.+)/); + return match ? match[1].trim() : ""; +} + +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) if (existsSync(path.join(candidate, ".project")) && existsSync(path.join(candidate, ".agents"))) return candidate; + return path.resolve(startDir, ".."); +} diff --git a/.agents/scripts/lease-manager.mjs b/.agents/scripts/lease-manager.mjs new file mode 100644 index 0000000..69ab216 --- /dev/null +++ b/.agents/scripts/lease-manager.mjs @@ -0,0 +1,88 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const command = process.argv[2] || "self-test"; +const statePath = readOption("--state") || path.join(repoRoot, ".agents", "leases", "active-leases.json"); + +try { + const result = run(command, statePath); + if (process.argv.includes("--json")) console.log(JSON.stringify(result, null, 2)); + else console.log(result.message); +} catch (error) { + console.error(error.message); + process.exit(error.exitCode || 1); +} + +export function run(command, statePath) { + if (command === "self-test") return selfTest(); + if (command === "list" || command === "inspect") return { message: `Lease inspection found ${readState(statePath).leases.length} lease(s).`, leases: readState(statePath).leases }; + if (command === "acquire") return acquire(statePath, readRequired("--owner"), readRequired("--project"), readRequired("--task"), readList("--zone"), readOption("--mode") || "shared", Number(readOption("--ttl-minutes") || 120)); + if (command === "release") return release(statePath, readRequired("--lease-id"), readOption("--reason") || "released by owner", readOption("--handoff") || ""); + throw Object.assign(new Error(`Unknown lease command: ${command}`), { exitCode: 1 }); +} + +function acquire(statePath, owner, project, taskId, zones, mode, ttlMinutes) { + if (!zones.length) throw Object.assign(new Error("At least one --zone is required."), { exitCode: 1 }); + if (!["shared", "exclusive"].includes(mode)) throw Object.assign(new Error("--mode must be shared or exclusive."), { exitCode: 1 }); + const state = readState(statePath); + const now = new Date(); + const lease = { + schema_version: 1, + lease_id: `lease-${now.toISOString().replace(/[:.]/g, "-")}-${taskId.toLowerCase()}`, + owner, + project, + task_id: taskId, + status: "active", + mode, + paths: zones, + conflict_zones: zones, + created_at: now.toISOString(), + acquired_at: now.toISOString(), + expires_at: new Date(now.getTime() + ttlMinutes * 60_000).toISOString() + }; + state.leases.push(lease); + writeState(statePath, state); + return { message: `Acquired ${lease.lease_id} for ${project}/${taskId}.`, lease }; +} + +function release(statePath, leaseId, reason, handoff) { + const state = readState(statePath); + const lease = state.leases.find((item) => item.lease_id === leaseId); + if (!lease) throw Object.assign(new Error(`Lease not found: ${leaseId}`), { exitCode: 1 }); + if (!handoff.trim()) { + throw Object.assign(new Error("--handoff is required and must summarize changed work, evidence, blockers, lease state, and next safe action."), { exitCode: 1 }); + } + lease.status = "released"; + lease.released_at = new Date().toISOString(); + lease.release_reason = reason; + lease.handoff_summary = handoff.trim(); + writeState(statePath, state); + return { message: `Released ${leaseId} with handoff summary.`, lease }; +} + +function selfTest() { + const dir = path.join(os.tmpdir(), `delano-lease-${process.pid}`); + const state = path.join(dir, "leases.json"); + mkdirSync(dir, { recursive: true }); + const acquired = acquire(state, "self-test", "delano-multi-agent-execution", "T-002", ["scripts/lease-manager.mjs"], "exclusive", 5).lease; + const inspected = readState(state).leases.length; + release(state, acquired.lease_id, "self-test complete", "validated acquire inspect release lifecycle"); + const released = readState(state).leases[0].status; + rmSync(dir, { recursive: true, force: true }); + return { message: `Lease manager self-test passed (${inspected} acquired, ${released}).` }; +} + +function readState(filePath) { + if (!existsSync(filePath)) return { schema_version: 1, leases: [] }; + return JSON.parse(readFileSync(filePath, "utf8")); +} +function writeState(filePath, state) { mkdirSync(path.dirname(filePath), { recursive: true }); writeFileSync(filePath, JSON.stringify(state, null, 2) + "\n"); } +function readOption(name) { const i = process.argv.indexOf(name); return i === -1 ? "" : process.argv[i + 1]; } +function readRequired(name) { const v = readOption(name); if (!v) throw Object.assign(new Error(`${name} is required.`), { exitCode: 1 }); return v; } +function readList(name) { const out=[]; process.argv.forEach((arg,i)=>{ if(arg===name && process.argv[i+1]) out.push(process.argv[i+1]); }); return out; } +function resolveRepoRoot(startDir) { for (const c of [path.resolve(startDir,".."),path.resolve(startDir,"..","..")]) if (existsSync(path.join(c,".agents"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/log-event.js b/.agents/scripts/log-event.js new file mode 100644 index 0000000..621d9e3 --- /dev/null +++ b/.agents/scripts/log-event.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { redactObject } = require('../common/log-safety'); + +const args = process.argv.slice(2); +if (args.length < 2) { + console.error('Usage: log-event.js [--key value ...]'); + process.exit(1); +} + +const [type, actor, ...rest] = args; +const event = { + timestamp: new Date().toISOString(), + type, + actor, + meta: {} +}; + +for (let i = 0; i < rest.length; i++) { + const token = rest[i]; + if (!token.startsWith('--')) continue; + const key = token.slice(2); + const value = rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[++i] : 'true'; + event.meta[key] = value; +} + +event.meta = redactObject(event.meta); + +const root = process.cwd(); +const logDir = path.join(root, '.agents', 'logs'); +const logFile = path.join(logDir, 'changes.jsonl'); +fs.mkdirSync(logDir, { recursive: true }); +fs.appendFileSync(logFile, JSON.stringify(event) + '\n', 'utf8'); + +console.log(`logged ${type}`); diff --git a/.agents/scripts/log-event.sh b/.agents/scripts/log-event.sh new file mode 100644 index 0000000..549f8cd --- /dev/null +++ b/.agents/scripts/log-event.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +node "$script_dir/log-event.js" "$@" diff --git a/.agents/scripts/plan-sync-repairs.mjs b/.agents/scripts/plan-sync-repairs.mjs new file mode 100644 index 0000000..bd5bd23 --- /dev/null +++ b/.agents/scripts/plan-sync-repairs.mjs @@ -0,0 +1,66 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildDriftReport } from "./build-drift-report.mjs"; +import { readLocalSyncMap } from "./read-local-sync-map.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const args = new Set(process.argv.slice(2)); +const jsonMode = args.has("--json"); +const apply = args.has("--apply"); +const approve = readOption("--approve"); + +const report = buildDriftReport(readLocalSyncMap(repoRoot), {}, {}, { localOnlyMode: true }); +const plan = buildRepairPlan(report, { apply, approve }); + +if (jsonMode) console.log(JSON.stringify(plan, null, 2)); +else { + console.log(`Repair plan produced ${plan.summary.action_count} action(s).`); + console.log(`Apply gate: ${plan.apply_gate.status}.`); + for (const action of plan.actions) console.log(`- ${action.mode}: ${action.target} ${action.summary}`); +} + +if (apply && plan.apply_gate.status !== "approved") { + if (!jsonMode) console.error(`Refusing apply: ${plan.apply_gate.reason}`); + process.exit(2); +} + +export function buildRepairPlan(report, options = {}) { + const actions = (report.repair_recommendations || []).map((recommendation) => ({ + id: recommendation.id, + mode: "dry-run-plan", + drift_type: recommendation.drift_type, + target: recommendation.target, + summary: recommendation.summary, + proposed_action: recommendation.proposed_action, + apply_posture: recommendation.apply_posture || "dry-run-plan-first", + evidence: recommendation.evidence || {} + })); + const token = `APPLY-${report.schema_version}-${report.summary.drift_count}-${report.summary.repair_count}`; + const approved = options.apply && options.approve === token; + return { + schema_version: 1, + mode: options.apply ? "apply-request" : "dry-run-plan", + source_report: { schema_version: report.schema_version, generated_at: report.generated_at, drift_count: report.summary.drift_count }, + apply_gate: { + status: approved ? "approved" : "blocked", + required_token: token, + provided_token: options.approve || "", + reason: approved ? "Explicit operator token matched; caller may perform a separate apply step." : "Explicit operator approval token is required before any local or remote mutation." + }, + summary: { action_count: actions.length, dry_run: !approved, mutation_count: 0 }, + actions + }; +} + +function readOption(name) { + const index = process.argv.indexOf(name); + return index === -1 ? "" : process.argv[index + 1]; +} +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) if (existsSync(path.join(candidate, ".project")) && existsSync(path.join(candidate, ".agents"))) return candidate; + return path.resolve(startDir, ".."); +} diff --git a/.agents/scripts/pm/blocked.sh b/.agents/scripts/pm/blocked.sh new file mode 100644 index 0000000..0471cfd --- /dev/null +++ b/.agents/scripts/pm/blocked.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +fm_get() { + local file="$1" + local key="$2" + awk -v key="$key" ' + BEGIN {in_fm=0} + /^---[[:space:]]*$/ {if (in_fm==0) {in_fm=1; next} else {exit}} + in_fm==1 && $0 ~ "^" key ":[[:space:]]*" { + sub("^" key ":[[:space:]]*", "") + print + exit + } + ' "$file" +} + +found=0 +for task in .project/projects/*/tasks/*.md; do + [[ -f "$task" ]] || continue + status="$(fm_get "$task" status 2>/dev/null || true)" + [[ "$status" == "blocked" ]] || continue + + project="$(basename "$(dirname "$(dirname "$task")")")" + tid="$(fm_get "$task" id 2>/dev/null || basename "$task" .md)" + name="$(fm_get "$task" name 2>/dev/null || basename "$task" .md)" + echo "$project\t$tid\t$name\t$task" + found=1 +done + +if [[ $found -eq 0 ]]; then + echo "No blocked tasks." +fi diff --git a/.agents/scripts/pm/epic-list.sh b/.agents/scripts/pm/epic-list.sh new file mode 100644 index 0000000..df1514d --- /dev/null +++ b/.agents/scripts/pm/epic-list.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +fm_get() { + local file="$1" + local key="$2" + awk -v key="$key" ' + BEGIN {in_fm=0} + /^---[[:space:]]*$/ {if (in_fm==0) {in_fm=1; next} else {exit}} + in_fm==1 && $0 ~ "^" key ":[[:space:]]*" { + sub("^" key ":[[:space:]]*", "") + print + exit + } + ' "$file" +} + +found=0 +for plan in .project/projects/*/plan.md; do + [[ -f "$plan" ]] || continue + slug="$(basename "$(dirname "$plan")")" + name="$(fm_get "$plan" name 2>/dev/null || echo "$slug")" + status="$(fm_get "$plan" status 2>/dev/null || echo "unknown")" + lead="$(fm_get "$plan" lead 2>/dev/null || true)" + echo "$slug\t$name\t$status\t$lead" + found=1 +done + +if [[ $found -eq 0 ]]; then + echo "No delivery plans found." +fi diff --git a/.agents/scripts/pm/import-spec-kit.sh b/.agents/scripts/pm/import-spec-kit.sh new file mode 100644 index 0000000..5179d27 --- /dev/null +++ b/.agents/scripts/pm/import-spec-kit.sh @@ -0,0 +1,605 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + import-spec-kit.sh [options] + import-spec-kit.sh [project-name] [owner] [lead] + +Creates a planned Delano project from the first supported Spec Kit-style markdown fixture. + +Required arguments: + slug Target Delano project slug in kebab-case + source-md Path to a markdown source artifact + +Options: + --name Project name override + --owner Project owner, defaults to team + --lead Project lead, defaults to owner + --no-validate Create artifacts without running Delano validation + --json Print a single machine-readable JSON result + -h, --help Show this help + +Agent notes: + - Prefer named options over positional metadata. + - Use --json when another agent/tool will parse the result. + - The command refuses to overwrite an existing .project/projects// folder. + - Generated artifacts stay planned/ready and still require Delano evidence gates. +USAGE +} + +resolve_python() { + if command -v python3 >/dev/null 2>&1 && python3 -c "import sys" >/dev/null 2>&1; then + PYTHON_CMD=(python3) + elif command -v py >/dev/null 2>&1 && py -3 -c "import sys" >/dev/null 2>&1; then + PYTHON_CMD=(py -3) + elif command -v python >/dev/null 2>&1 && python -c "import sys" >/dev/null 2>&1; then + PYTHON_CMD=(python) + else + echo "Error: Python runtime not found. Install python3, python, or py -3." >&2 + exit 1 + fi +} + +resolve_python + +json_escape() { + "${PYTHON_CMD[@]}" -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))' +} + +if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ "${2:-}" == "" ]]; then + usage + exit 1 +fi + +slug="$1" +source_md="$2" +shift 2 + +project_name="" +owner="team" +lead="" +validate="true" +json="false" +positional=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --name) + project_name="${2:-}" + if [[ -z "$project_name" ]]; then echo "Error: --name requires a value"; exit 1; fi + shift 2 + ;; + --owner) + owner="${2:-}" + if [[ -z "$owner" ]]; then echo "Error: --owner requires a value"; exit 1; fi + shift 2 + ;; + --lead) + lead="${2:-}" + if [[ -z "$lead" ]]; then echo "Error: --lead requires a value"; exit 1; fi + shift 2 + ;; + --no-validate) + validate="false" + shift + ;; + --json) + json="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + while [[ $# -gt 0 ]]; do positional+=("$1"); shift; done + ;; + --*) + echo "Error: unknown option: $1" + exit 1 + ;; + *) + positional+=("$1") + shift + ;; + esac +done + +# Backward-compatible positional metadata: [project-name] [owner] [lead]. +if [[ ${#positional[@]} -gt 0 && -z "$project_name" ]]; then + project_name="${positional[0]}" +fi +if [[ ${#positional[@]} -gt 1 && "$owner" == "team" ]]; then + owner="${positional[1]}" +fi +if [[ ${#positional[@]} -gt 2 && -z "$lead" ]]; then + lead="${positional[2]}" +fi +if [[ ${#positional[@]} -gt 3 ]]; then + echo "Error: too many positional arguments" + exit 1 +fi + +lead="${lead:-$owner}" + +if [[ ! "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "Error: slug must be kebab-case" + exit 1 +fi + +invocation_cwd="$PWD" +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +if [[ "$source_md" != /* ]]; then + source_md="$invocation_cwd/$source_md" +fi +cd "$root" + +if [[ ! -f "$source_md" ]]; then + echo "Error: source markdown not found: $source_md" + exit 1 +fi + +project_dir=".project/projects/$slug" +if [[ -d "$project_dir" ]]; then + echo "Error: project already exists at $project_dir" + exit 1 +fi + +"${PYTHON_CMD[@]}" - "$slug" "$source_md" "$project_name" "$owner" "$lead" <<'PY' +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +slug, source_arg, project_name_arg, owner, lead = sys.argv[1:] +source_path = Path(source_arg) +root = Path.cwd() +source_text = source_path.read_text(encoding="utf-8") +now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") +today = now[:10] + +def write_file(path, text): + with path.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(text) + +heading_match = re.search(r"^#\s+(?:Specification|Spec):\s+(.+?)\s*$", source_text, re.MULTILINE | re.IGNORECASE) +if project_name_arg: + project_name = project_name_arg.strip() +elif heading_match: + project_name = heading_match.group(1).strip() +else: + project_name = slug.replace("-", " ").title() + +sections = {} +current = None +for line in source_text.splitlines(): + match = re.match(r"^##\s+(.+?)\s*$", line) + if match: + current = match.group(1).strip().lower() + sections[current] = [] + continue + if current: + sections[current].append(line) + +for key in list(sections): + sections[key] = "\n".join(sections[key]).strip() + +def section(*names): + for name in names: + value = sections.get(name.lower()) + if value: + return value + return "" + +def bullet_lines(text): + items = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("- "): + items.append(stripped) + return items + +user_stories = bullet_lines(section("User Stories")) +acceptance = bullet_lines(section("Acceptance Scenarios")) +requirements = bullet_lines(section("Requirements")) +non_functional = bullet_lines(section("Non-Functional Requirements")) +assumptions = bullet_lines(section("Assumptions")) +clarifications = bullet_lines(section("Clarifications", "Needs Clarification")) +implementation_plan = bullet_lines(section("Implementation Plan")) +raw_tasks = bullet_lines(section("Tasks")) + +recognized_content = user_stories + acceptance + requirements + non_functional + assumptions + clarifications + implementation_plan + raw_tasks +if not recognized_content: + raise SystemExit("unsupported Spec Kit-style source: expected at least one recognized section such as User Stories, Acceptance Scenarios, Requirements, Implementation Plan, or Tasks") + +project_dir = root / ".project" / "projects" / slug +(project_dir / "tasks").mkdir(parents=True) +(project_dir / "workstreams").mkdir() +(project_dir / "updates").mkdir() + +source_display = source_path.as_posix() +if source_path.is_absolute(): + try: + source_display = source_path.resolve().relative_to(root).as_posix() + except ValueError: + source_display = "external markdown source" + +def md_list(items, fallback="- None recorded."): + return "\n".join(items) if items else fallback + +def yaml_scalar(value): + return str(value).replace("\n", " ").replace(":", " -") + +spec = f"""--- +name: {yaml_scalar(project_name)} +slug: {slug} +owner: {yaml_scalar(owner)} +status: planned +created: {now} +updated: {now} +outcome: Imported Spec Kit-style intent is normalized into Delano delivery contracts and validated before execution. +uncertainty: medium +probe_required: true +probe_status: pending +--- + +# Spec: {project_name} + +## Executive Summary + +Imported from a Spec Kit-style markdown artifact. This project is planned until clarification, probe, and validation gates confirm the generated contracts are execution-ready. + +## Problem and Users + +{md_list(user_stories)} + +## Outcome and Success Metrics + +Acceptance scenarios imported as success signals: + +{md_list(acceptance)} + +## User Stories + +{md_list(user_stories)} + +## Acceptance Scenarios + +{md_list(acceptance)} + +## Scope + +### In Scope + +{md_list(requirements)} + +### Out of Scope + +- Automatic execution before Delano validation and approval. +- Automatic external sync writes. + +## Functional Requirements + +{md_list(requirements)} + +## Non-Functional Requirements + +{md_list(non_functional)} + +## Assumptions + +{md_list(assumptions)} + +## Needs Clarification + +{md_list(clarifications)} + +## Hypotheses and Unknowns + +Assumptions imported from source: + +{md_list(assumptions)} + +## Touchpoints to Exercise + +- Generated Delano project contracts. +- Task dependency and evidence validation. +- Import update note. + +## Probe Findings + +Pending. Run a delivery probe before activating this spec. + +## Footguns Discovered + +- Imported intent may contain assumptions that need operator confirmation. +- Imported tasks may not include enough evidence detail for closure. + +## Remaining Unknowns + +{md_list(clarifications)} + +## Dependencies + +- Source artifact: `{source_display}` + +## Approval Notes + +Imported by `delano import-spec-kit`. Review before activation. +""" +write_file(project_dir / "spec.md", spec) + +plan = f"""--- +name: {yaml_scalar(project_name)} +status: planned +lead: {yaml_scalar(lead)} +created: {now} +updated: {now} +linear_project_id: +risk_level: medium +spec_status_at_plan_time: planned +--- + +# Delivery Plan: {project_name} + +## What Changed After Probe + +No probe has been run yet. This plan was imported from a Spec Kit-style source and requires review. + +## Architecture Decisions + +Imported implementation plan: + +{md_list(implementation_plan)} + +## Probe-Driven Architecture Changes + +Pending probe. + +## Workstream Design + +- WS-A Imported Delivery Foundation: first normalized workstream for imported tasks. + +## Milestone Strategy + +1. Review imported spec and clarify open questions. +2. Run required probe. +3. Execute ready tasks with Delano evidence gates. + +## Rollout Strategy + +Start with local validation and evidence collection. Do not sync externally until identity mappings are reviewed. + +## Test Strategy + +- Run `delano validate` after import. +- Add task-specific tests before closure. + +## Rollback Strategy + +If the import is wrong, remove `.project/projects/{slug}` before external sync or activation. + +## Remaining Delivery Risks + +- Source assumptions may be incomplete. +- Imported task boundaries may need workstream refinement. +- Clarifications may block activation. +""" +write_file(project_dir / "plan.md", plan) + +write_file(project_dir / "decisions.md", "# Decisions\n\nTrack key project decisions with context and rationale.\n") + +workstream = f"""--- +name: WS-A Imported Delivery Foundation +owner: {yaml_scalar(owner)} +status: planned +created: {now} +updated: {now} +--- + +# Workstream: WS-A Imported Delivery Foundation + +## Objective + +Normalize and execute the imported Spec Kit-style task set under Delano governance. + +## Owned Files/Areas + +- `.project/projects/{slug}/` + +## Dependencies + +- Source artifact review. +- Delano validation. + +## Risks + +- Imported tasks may require clarification before execution. +- Parallel markers are hints and still need conflict review. + +## Handoff Criteria + +- Tasks have evidence logs. +- Validation passes before closure. +""" +write_file(project_dir / "workstreams" / "WS-A-imported-delivery-foundation.md", workstream) + +def slugify(text): + text = re.sub(r"^T\d+\s+", "", text.strip(), flags=re.IGNORECASE) + text = re.sub(r"[^a-zA-Z0-9]+", "-", text.lower()).strip("-") + return text or "imported-task" + +if not raw_tasks: + raw_tasks = ["- [ ] Review imported Spec Kit artifact and define executable Delano tasks."] + +acceptance_ids = [] +for index, item in enumerate(acceptance, start=1): + match = re.search(r"\bAC[-_ ]?(\d{1,3})\b", item, re.IGNORECASE) + acceptance_ids.append(f"AC-{int(match.group(1)):03d}" if match else f"AC-{index:03d}") + +def parse_task(raw, index): + text = raw.strip() + parallel = bool(re.search(r"\[(?:P|p)\]", text)) + source_task_match = re.search(r"\bT[-_ ]?(\d{1,4})\b", text, re.IGNORECASE) + story_match = re.search(r"\b(?:US|Story)[-_ ]?(\d{1,3})\b", text, re.IGNORECASE) + story_id = f"US-{int(story_match.group(1)):03d}" if story_match else "" + + title = re.sub(r"^-\s*", "", text).strip() + title = re.sub(r"^\[(?: |x|X|P|p)\]\s*", "", title).strip() + title = re.sub(r"\[(?:P|p)\]", "", title).strip() + title = re.sub(r"\[(?:US|Story)[-_ ]?\d{1,3}\]", "", title, flags=re.IGNORECASE).strip() + title = re.sub(r"^T[-_ ]?\d{1,4}[:.)-]?\s*", "", title, flags=re.IGNORECASE).strip() + title = re.sub(r"^\[[^\]]+\]\s*", "", title).strip() + title = title or f"Review imported task {index}" + + vague = bool(re.search(r"\b(tbd|todo|clarify|needs clarification|unknown|investigate|research)\b", raw, re.IGNORECASE)) + generated_review_task = index == 1 and "Review imported Spec Kit artifact" in raw + blocked = bool(clarifications) or vague or generated_review_task + status = "blocked" if blocked else "ready" + reason = "Open clarifications or vague source wording require review before execution." if blocked else "No source clarification blocker detected by importer." + source_task_id = f"T{int(source_task_match.group(1)):03d}" if source_task_match else "" + return title, parallel, status, reason, story_id, source_task_id + +for index, raw in enumerate(raw_tasks, start=1): + title, parallel, status, block_reason, story_id, source_task_id = parse_task(raw, index) + task_id = f"T-{index:03d}" + task_slug = slugify(title) + acceptance_yaml = "[" + ", ".join(acceptance_ids) + "]" if acceptance_ids else "[]" + blocker_frontmatter = "" + blocker_section = "" + if status == "blocked": + blocker_frontmatter = f"blocked_owner: {yaml_scalar(owner)}\nblocked_check_back: {today}\n" + blocker_section = f"\n## Blocker\n\n{block_reason}\n" + task = f"""--- +id: {task_id} +name: {yaml_scalar(title)} +status: {status} +workstream: WS-A +created: {now} +updated: {now} +linear_issue_id: +github_issue: +github_pr: +depends_on: [] +conflicts_with: [] +parallel: {str(parallel).lower()} +priority: medium +estimate: M +story_id: {story_id} +acceptance_criteria_ids: {acceptance_yaml} +{blocker_frontmatter}--- + +# Task: {title} + +## Description + +Imported from Spec Kit-style source task: `{raw}` + +## Acceptance Criteria + +- [ ] Task has been reviewed against the imported acceptance scenarios. +- [ ] Implementation satisfies relevant Delano evidence requirements. + +## Traceability + +- Source task id: {source_task_id or "none detected"} +- Story: {story_id or "none detected"} +- Acceptance criteria: {", ".join(acceptance_ids) if acceptance_ids else "none detected"} +{blocker_section} +## Technical Notes + +- Source artifact: `{source_display}` +- Parallel marker imported: `{str(parallel).lower()}` +- Initial status: `{status}` + +## Definition of Done + +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated if behavior is user-visible +- [ ] Evidence recorded + +## Evidence Log + +- {now}: Imported from Spec Kit-style markdown by `delano import-spec-kit`. +""" + write_file(project_dir / "tasks" / f"{task_id}-{task_slug}.md", task) + +update = f"""# Imported from Spec Kit-style artifact + +Imported `{source_display}` into Delano project `{slug}`. + +## Source classification + +- Shape: single-file Spec Kit-style markdown fixture. +- Confidence: initial supported fixture shape. + +## Imported counts + +- User stories: {len(user_stories)} +- Acceptance scenarios: {len(acceptance)} +- Functional requirements: {len(requirements)} +- Non-functional requirements: {len(non_functional)} +- Clarifications: {len(clarifications)} +- Tasks: {len(raw_tasks)} + +## Unresolved clarifications + +{md_list(clarifications)} + +## Next step + +Review the generated project, then run Delano validation and a probe before activation. +""" +write_file(project_dir / "updates" / f"{today}-imported-from-spec-kit.md", update) +PY + +validation_status="skipped" +ok="true" +error="" +if [[ "$validate" == "true" ]]; then + if [[ "$json" == "true" ]]; then + validation_log="$(mktemp)" + if "$root/.agents/scripts/pm/validate.sh" >"$validation_log" 2>&1; then + validation_status="passed" + else + validation_status="failed" + ok="false" + error="validation_failed" + fi + rm -f "$validation_log" + else + "$root/.agents/scripts/pm/validate.sh" + validation_status="passed" + fi +fi + +if [[ "$json" == "true" ]]; then + project_json="$(printf '%s' "$project_dir" | json_escape)" + source_display="$source_md" + case "$source_display" in + "$root"/*) source_display="${source_display#"$root/"}" ;; + esac + source_json="$(printf '%s' "$source_display" | json_escape)" + validation_json="$(printf '%s' "$validation_status" | json_escape)" + if [[ "$ok" == "true" ]]; then + printf '{"ok":true,"command":"import-spec-kit","project":%s,"source":%s,"validation":%s}\n' "$project_json" "$source_json" "$validation_json" + else + error_json="$(printf '%s' "$error" | json_escape)" + printf '{"ok":false,"command":"import-spec-kit","project":%s,"source":%s,"validation":%s,"error":%s}\n' "$project_json" "$source_json" "$validation_json" "$error_json" + exit 1 + fi +else + echo "Created Delano project from Spec Kit-style artifact: $project_dir" + echo "Validation: $validation_status" + echo "Next: review $project_dir/spec.md, then run a probe before activation." +fi diff --git a/.agents/scripts/pm/in-progress.sh b/.agents/scripts/pm/in-progress.sh new file mode 100644 index 0000000..872b981 --- /dev/null +++ b/.agents/scripts/pm/in-progress.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +fm_get() { + local file="$1" + local key="$2" + awk -v key="$key" ' + BEGIN {in_fm=0} + /^---[[:space:]]*$/ {if (in_fm==0) {in_fm=1; next} else {exit}} + in_fm==1 && $0 ~ "^" key ":[[:space:]]*" { + sub("^" key ":[[:space:]]*", "") + print + exit + } + ' "$file" +} + +found=0 +for task in .project/projects/*/tasks/*.md; do + [[ -f "$task" ]] || continue + status="$(fm_get "$task" status 2>/dev/null || true)" + if [[ "$status" == "in-progress" || "$status" == "review" ]]; then + project="$(basename "$(dirname "$(dirname "$task")")")" + tid="$(fm_get "$task" id 2>/dev/null || basename "$task" .md)" + name="$(fm_get "$task" name 2>/dev/null || basename "$task" .md)" + echo "$project\t$tid\t$status\t$name" + found=1 + fi +done + +if [[ $found -eq 0 ]]; then + echo "No tasks in progress or review." +fi diff --git a/.agents/scripts/pm/init.sh b/.agents/scripts/pm/init.sh new file mode 100644 index 0000000..2cc7e5e --- /dev/null +++ b/.agents/scripts/pm/init.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "" || "${2:-}" == "" ]]; then + echo "Usage: $0 [owner] [lead]" + exit 1 +fi + +slug="$1" +name="$2" +owner="${3:-team}" +lead="${4:-$owner}" + +if [[ ! "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "Error: slug must be kebab-case" + exit 1 +fi + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +project_dir="$root/.project/projects/$slug" +now="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +if [[ -d "$project_dir" ]]; then + echo "Error: project already exists at $project_dir" + exit 1 +fi + +mkdir -p "$project_dir"/{tasks,workstreams,updates} + +cat > "$project_dir/spec.md" < +uncertainty: +probe_required: +probe_status: +--- + +# Spec: $name + +## Executive Summary + +## Problem and Users + +## Outcome and Success Metrics + +## User Stories +- US-001: As a , I want , so that . + +## Acceptance Scenarios +- AC-001: Given , when , then . + +## Scope +### In Scope +### Out of Scope + +## Functional Requirements + +## Non-Functional Requirements + +## Assumptions +- + +## Needs Clarification +- + +## Hypotheses and Unknowns + +## Touchpoints to Exercise + +## Probe Findings + +## Footguns Discovered + +## Remaining Unknowns + +## Dependencies + +## Approval Notes +SPEC + +cat > "$project_dir/plan.md" < +spec_status_at_plan_time: planned +--- + +# Delivery Plan: $name + +## What Changed After Probe + +## Technical Context + +## Architecture Decisions + +## Policy and Contract Checks +- [ ] `.project` remains the execution source of truth +- [ ] Probe decision is explicit +- [ ] Evidence gates are defined before handoff +- [ ] External sync writes require dry-run or operator approval + +## Generated Artifact Map +- `spec.md`: +- `plan.md`: +- `workstreams/`: +- `tasks/`: + +## Complexity Exceptions +- + +## Probe-Driven Architecture Changes + +## Workstream Design + +## Milestone Strategy + +## Rollout Strategy + +## Test Strategy + +## Rollback Strategy + +## Remaining Delivery Risks +PLAN + +cat > "$project_dir/decisions.md" <<'DECISIONS' +# Decisions + +Track key project decisions with context and rationale. +DECISIONS + +# Ensure registries exist +mkdir -p "$root/.project/registry" +if [[ ! -f "$root/.project/registry/linear-map.json" ]]; then + cat > "$root/.project/registry/linear-map.json" < "$root/.project/registry/migration-map.json" </dev/null 2>&1 && python3 -c "import sys" >/dev/null 2>&1; then + python_cmd=(python3) + return 0 + fi + + if command -v py >/dev/null 2>&1 && py -3 -c "import sys" >/dev/null 2>&1; then + python_cmd=(py -3) + return 0 + fi + + if command -v python >/dev/null 2>&1 && python -c "import sys" >/dev/null 2>&1; then + python_cmd=(python) + return 0 + fi + + echo "No usable Python runtime found (tried: python3, py -3, python)." >&2 + exit 1 +} + +show_all=false +if [[ "${1:-}" == "--all" ]]; then + show_all=true +fi + +if [[ "$show_all" == "true" ]]; then + export DELANO_NEXT_ALL=1 +else + export DELANO_NEXT_ALL=0 +fi + +resolve_python_cmd + +"${python_cmd[@]}" - <<'PY' +import re +from pathlib import Path + +ROOT = Path('.') +TASK_STATUS_READY = 'ready' + +rank = {'urgent': 0, 'high': 1, 'medium': 2, 'low': 3} + +def parse_frontmatter(path: Path): + text = path.read_text(encoding='utf-8') + m = re.match(r'^---\n(.*?)\n---\n', text, re.S) + if not m: + return {} + data = {} + for line in m.group(1).splitlines(): + if ':' not in line: + continue + k, v = line.split(':', 1) + data[k.strip()] = v.strip() + return data + +def parse_list(raw: str): + raw = (raw or '[]').strip() + if raw.startswith('[') and raw.endswith(']'): + inner = raw[1:-1].strip() + if not inner: + return [] + return [x.strip().strip('"\'') for x in inner.split(',') if x.strip()] + return [] + +projects = sorted((ROOT / '.project' / 'projects').glob('*')) +all_candidates = [] +for p in projects: + if not p.is_dir() or p.name == '.gitkeep': + continue + tasks = {} + for tf in sorted((p / 'tasks').glob('*.md')): + meta = parse_frontmatter(tf) + tid = meta.get('id', tf.stem) + tasks[tid] = { + 'path': tf, + 'name': meta.get('name', tf.stem), + 'status': meta.get('status', ''), + 'depends_on': parse_list(meta.get('depends_on', '[]')), + 'priority': meta.get('priority', 'medium').lower(), + } + + for tid, t in tasks.items(): + if t['status'] != TASK_STATUS_READY: + continue + unmet = [d for d in t['depends_on'] if d in tasks and tasks[d]['status'] != 'done'] + if unmet: + continue + all_candidates.append((rank.get(t['priority'], 4), p.name, tid, t['name'], t['priority'], str(t['path']))) + +all_candidates.sort() +if not all_candidates: + print('No dependency-safe ready tasks found.') + raise SystemExit(0) + +import os +show_all = os.environ.get('DELANO_NEXT_ALL', '0') == '1' +if not show_all: + all_candidates = all_candidates[:1] + +for _, project, tid, name, prio, path in all_candidates: + print(f'{project}\t{tid}\t{prio}\t{name}\t{path}') +PY diff --git a/.agents/scripts/pm/prd-list.sh b/.agents/scripts/pm/prd-list.sh new file mode 100644 index 0000000..66f6f3b --- /dev/null +++ b/.agents/scripts/pm/prd-list.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +fm_get() { + local file="$1" + local key="$2" + awk -v key="$key" ' + BEGIN {in_fm=0} + /^---[[:space:]]*$/ {if (in_fm==0) {in_fm=1; next} else {exit}} + in_fm==1 && $0 ~ "^" key ":[[:space:]]*" { + sub("^" key ":[[:space:]]*", "") + print + exit + } + ' "$file" +} + +found=0 +for spec in .project/projects/*/spec.md; do + [[ -f "$spec" ]] || continue + slug="$(basename "$(dirname "$spec")")" + name="$(fm_get "$spec" name 2>/dev/null || echo "$slug")" + status="$(fm_get "$spec" status 2>/dev/null || echo "unknown")" + outcome="$(fm_get "$spec" outcome 2>/dev/null || true)" + echo "$slug\t$name\t$status\t$outcome" + found=1 +done + +if [[ $found -eq 0 ]]; then + echo "No specs found." +fi diff --git a/.agents/scripts/pm/research.sh b/.agents/scripts/pm/research.sh new file mode 100644 index 0000000..d822f0b --- /dev/null +++ b/.agents/scripts/pm/research.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + research.sh [options] + +Creates a repo-native Delano research intake folder for a project. + +Required arguments: + project-slug Existing Delano project slug + research-slug Research folder slug in kebab-case + +Options: + --title Human-readable research title + --question <question> Primary research question + --owner <owner> Research owner, defaults to team + --no-validate Create artifacts without running Delano validation + --json Print a single machine-readable JSON result + -h, --help Show this help + +Agent notes: + - Use this before mutating spec/plan/tasks when intent is unclear. + - Update findings.md and progress.md during investigation. + - Fold durable conclusions forward into spec.md, plan.md, decisions.md, workstreams, tasks, or updates. + - Research files are supporting discovery state, not executable task truth. +USAGE +} + +resolve_python() { + if command -v python3 >/dev/null 2>&1 && python3 -c "import sys" >/dev/null 2>&1; then + PYTHON_CMD=(python3) + elif command -v py >/dev/null 2>&1 && py -3 -c "import sys" >/dev/null 2>&1; then + PYTHON_CMD=(py -3) + elif command -v python >/dev/null 2>&1 && python -c "import sys" >/dev/null 2>&1; then + PYTHON_CMD=(python) + else + echo "Error: Python runtime not found. Install python3, python, or py -3." >&2 + exit 1 + fi +} + +resolve_python + +json_escape() { + "${PYTHON_CMD[@]}" -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))' +} + +if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ "${2:-}" == "" ]]; then + usage + exit 1 +fi + +project_slug="$1" +research_slug="$2" +shift 2 + +title="" +question="" +owner="team" +validate="true" +json="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --title) + title="${2:-}" + if [[ -z "$title" ]]; then echo "Error: --title requires a value"; exit 1; fi + shift 2 + ;; + --question) + question="${2:-}" + if [[ -z "$question" ]]; then echo "Error: --question requires a value"; exit 1; fi + shift 2 + ;; + --owner) + owner="${2:-}" + if [[ -z "$owner" ]]; then echo "Error: --owner requires a value"; exit 1; fi + shift 2 + ;; + --no-validate) + validate="false" + shift + ;; + --json) + json="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + ;; + --*) + echo "Error: unknown option: $1" + exit 1 + ;; + *) + echo "Error: unexpected positional argument: $1" + exit 1 + ;; + esac +done + +if [[ ! "$project_slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "Error: project-slug must be kebab-case" + exit 1 +fi + +if [[ ! "$research_slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "Error: research-slug must be kebab-case" + exit 1 +fi + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +project_dir=".project/projects/$project_slug" +if [[ ! -d "$project_dir" ]]; then + echo "Error: Delano project not found: $project_dir" + exit 1 +fi + +research_dir="$project_dir/research/$research_slug" +if [[ -d "$research_dir" ]]; then + echo "Error: research intake already exists at $research_dir" + exit 1 +fi + +now="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +title="${title:-${research_slug//-/ }}" +question="${question:-<primary research question>}" + +mkdir -p "$research_dir" + +cat > "$research_dir/task_plan.md" <<PLAN +--- +type: research_intake +project: $project_slug +slug: $research_slug +owner: $owner +status: opened +created: $now +updated: $now +--- + +# Research Plan: $title + +## Goal + +Answer the research question and fold durable conclusions into canonical Delano project artifacts. + +## Primary Question + +$question + +## Scope + +### In Scope + +- Gather relevant evidence. +- Capture findings and decisions. +- Identify changes needed in \`spec.md\`, \`plan.md\`, \`decisions.md\`, workstreams, tasks, or updates. + +### Out of Scope + +- Marking delivery tasks done from research alone. +- External sync writes without normal Delano approval semantics. +- Storing secrets, credentials, or private machine paths. + +## Current Phase + +Opened + +## Phases + +- [x] Open research intake +- [ ] Investigate sources and options +- [ ] Summarize findings +- [ ] Fold forward into canonical project artifacts or explicitly close as no-action + +## Decisions Made + +| Decision | Rationale | +| --- | --- | + +## Blockers + +| Blocker | Owner | Check-back | +| --- | --- | --- | +PLAN + +cat > "$research_dir/findings.md" <<FINDINGS +--- +type: research_findings +project: $project_slug +slug: $research_slug +created: $now +updated: $now +--- + +# Findings: $title + +## Source References + +- <source, file, command, or artifact inspected> + +## Observations + +- <finding> + +## Options Considered + +| Option | Pros | Cons | Decision | +| --- | --- | --- | --- | + +## Fold-Forward Candidates + +| Finding | Target Artifact | Proposed Change | +| --- | --- | --- | + +## Open Questions + +- <question> +FINDINGS + +cat > "$research_dir/progress.md" <<PROGRESS +--- +type: research_progress +project: $project_slug +slug: $research_slug +created: $now +updated: $now +--- + +# Progress: $title + +## $now + +- Opened research intake for project \`$project_slug\`. +- Primary question: $question + +## Validation Evidence + +- Pending. + +## Handoff Summary + +- Pending. +PROGRESS + +validation_status="skipped" +ok="true" +error="" +if [[ "$validate" == "true" ]]; then + if [[ "$json" == "true" ]]; then + validation_log="$(mktemp)" + if "$root/.agents/scripts/pm/validate.sh" >"$validation_log" 2>&1; then + validation_status="passed" + else + validation_status="failed" + ok="false" + error="validation_failed" + fi + rm -f "$validation_log" + else + "$root/.agents/scripts/pm/validate.sh" + validation_status="passed" + fi +fi + +if [[ "$json" == "true" ]]; then + project_json="$(printf '%s' "$project_dir" | json_escape)" + research_json="$(printf '%s' "$research_dir" | json_escape)" + validation_json="$(printf '%s' "$validation_status" | json_escape)" + if [[ "$ok" == "true" ]]; then + printf '{"ok":true,"command":"research","project":%s,"research":%s,"files":["task_plan.md","findings.md","progress.md"],"validation":%s}\n' "$project_json" "$research_json" "$validation_json" + else + error_json="$(printf '%s' "$error" | json_escape)" + printf '{"ok":false,"command":"research","project":%s,"research":%s,"files":["task_plan.md","findings.md","progress.md"],"validation":%s,"error":%s}\n' "$project_json" "$research_json" "$validation_json" "$error_json" + exit 1 + fi +else + echo "Created Delano research intake: $research_dir" + echo "Files: task_plan.md, findings.md, progress.md" + echo "Validation: $validation_status" + echo "Next: update findings.md and progress.md, then fold conclusions into canonical Delano artifacts." +fi diff --git a/.agents/scripts/pm/search.sh b/.agents/scripts/pm/search.sh new file mode 100644 index 0000000..063d946 --- /dev/null +++ b/.agents/scripts/pm/search.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "" ]]; then + echo "Usage: $0 <query>" + exit 1 +fi + +query="$*" +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +grep -R -n -- "$query" .project/projects .project/context .project/registry || true diff --git a/.agents/scripts/pm/standup.sh b/.agents/scripts/pm/standup.sh new file mode 100644 index 0000000..555290e --- /dev/null +++ b/.agents/scripts/pm/standup.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +echo "Daily standup snapshot ($(date -u +"%Y-%m-%dT%H:%M:%SZ"))" +echo "" + +echo "[Portfolio]" +"$root/.agents/scripts/pm/status.sh" + +echo "" +echo "[In Progress]" +"$root/.agents/scripts/pm/in-progress.sh" + +echo "" +echo "[Blocked]" +"$root/.agents/scripts/pm/blocked.sh" diff --git a/.agents/scripts/pm/status.sh b/.agents/scripts/pm/status.sh new file mode 100644 index 0000000..cf48d64 --- /dev/null +++ b/.agents/scripts/pm/status.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +open_only=false +brief=false + +usage() { + cat <<'EOF' +Usage: + status.sh [--open] [--brief] + +Options: + --open Show only projects that are not closed. + --brief Show one compact line per project. + -h, --help + Show this help. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --open) + open_only=true + ;; + --brief) + brief=true + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown status option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +fm_get() { + local file="$1" + local key="$2" + local line + local in_fm=0 + while IFS= read -r line; do + if [[ "$line" =~ ^---[[:space:]]*$ ]]; then + if [[ $in_fm -eq 0 ]]; then + in_fm=1 + continue + fi + return 1 + fi + + if [[ $in_fm -eq 1 && "$line" == "$key:"* ]]; then + local value="${line#"$key:"}" + value="${value#"${value%%[![:space:]]*}"}" + printf '%s\n' "$value" + return 0 + fi + done < "$file" + return 1 +} + +is_closed_spec_status() { + local status="${1:-unknown}" + [[ "$status" == "complete" || "$status" == "deferred" ]] +} + +is_closed_plan_status() { + local status="${1:-unknown}" + [[ "$status" == "done" || "$status" == "deferred" ]] +} + +is_closed_task_status() { + local status="${1:-unknown}" + [[ "$status" == "done" || "$status" == "deferred" || "$status" == "canceled" ]] +} + +if [[ "$open_only" == "true" ]]; then + echo "Delano open project status" + echo "==========================" +else + echo "Delano portfolio status" + echo "=======================" +fi + +project_count=0 +printed_count=0 +for project_dir in .project/projects/*; do + [[ -d "$project_dir" ]] || continue + [[ "$(basename "$project_dir")" == ".gitkeep" ]] && continue + project_count=$((project_count + 1)) + + slug="$(basename "$project_dir")" + spec_status="$(fm_get "$project_dir/spec.md" status 2>/dev/null || true)" + plan_status="$(fm_get "$project_dir/plan.md" status 2>/dev/null || true)" + + total=0 + open_tasks=0 + backlog_count=0 + ready_count=0 + in_progress_count=0 + review_count=0 + done_count=0 + blocked_count=0 + deferred_count=0 + canceled_count=0 + unknown_count=0 + for task in "$project_dir"/tasks/*.md; do + [[ -f "$task" ]] || continue + status="$(fm_get "$task" status 2>/dev/null || true)" + total=$((total + 1)) + if ! is_closed_task_status "$status"; then + open_tasks=$((open_tasks + 1)) + fi + case "$status" in + backlog) backlog_count=$((backlog_count + 1)) ;; + ready) ready_count=$((ready_count + 1)) ;; + in-progress) in_progress_count=$((in_progress_count + 1)) ;; + review) review_count=$((review_count + 1)) ;; + done) done_count=$((done_count + 1)) ;; + blocked) blocked_count=$((blocked_count + 1)) ;; + deferred) deferred_count=$((deferred_count + 1)) ;; + canceled) canceled_count=$((canceled_count + 1)) ;; + *) unknown_count=$((unknown_count + 1)) ;; + esac + done + + project_open=false + if ! is_closed_spec_status "$spec_status" || ! is_closed_plan_status "$plan_status" || [[ $open_tasks -gt 0 ]]; then + project_open=true + fi + + if [[ "$open_only" == "true" && "$project_open" != "true" ]]; then + continue + fi + + printed_count=$((printed_count + 1)) + + if [[ "$brief" == "true" ]]; then + echo "${slug} spec=${spec_status:-unknown} plan=${plan_status:-unknown} open_tasks=${open_tasks} total_tasks=${total}" + else + echo "" + echo "Project: $slug" + echo " Spec status: ${spec_status:-unknown}" + echo " Plan status: ${plan_status:-unknown}" + [[ $backlog_count -gt 0 ]] && echo " backlog: $backlog_count" + [[ $ready_count -gt 0 ]] && echo " ready: $ready_count" + [[ $in_progress_count -gt 0 ]] && echo " in-progress: $in_progress_count" + [[ $review_count -gt 0 ]] && echo " review: $review_count" + [[ $done_count -gt 0 ]] && echo " done: $done_count" + [[ $blocked_count -gt 0 ]] && echo " blocked: $blocked_count" + [[ $deferred_count -gt 0 ]] && echo " deferred: $deferred_count" + [[ $canceled_count -gt 0 ]] && echo " canceled: $canceled_count" + [[ $unknown_count -gt 0 ]] && echo " unknown: $unknown_count" + echo " total tasks: $total" + fi +done + +if [[ $project_count -eq 0 ]]; then + echo "No projects found. Create one with: .agents/scripts/pm/init.sh <slug> <project-name>" +elif [[ "$open_only" == "true" && $printed_count -eq 0 ]]; then + echo "No open projects found." +fi diff --git a/.agents/scripts/pm/validate.sh b/.agents/scripts/pm/validate.sh new file mode 100644 index 0000000..01cb4d0 --- /dev/null +++ b/.agents/scripts/pm/validate.sh @@ -0,0 +1,981 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +errors=0 +warnings=0 + +check_required_path() { + local path="$1" + if [[ -e "$path" ]]; then + echo "✅ $path" + else + echo "❌ Missing: $path" + errors=$((errors + 1)) + fi +} + +fm_get() { + local file="$1" + local key="$2" + awk -v key="$key" ' + BEGIN {in_fm=0} + /^---[[:space:]]*$/ {if (in_fm==0) {in_fm=1; next} else {exit}} + in_fm==1 && $0 ~ "^" key ":[[:space:]]*" { + sub("^" key ":[[:space:]]*", "") + print + exit + } + ' "$file" +} + +has_frontmatter() { + local file="$1" + [[ "$(awk 'NR==1 && /^---[[:space:]]*$/ {print "yes"}' "$file")" == "yes" ]] +} + +is_iso_utc() { + local ts="$1" + [[ "$ts" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] +} + +contains_value() { + local needle="$1" + shift + local item + for item in "$@"; do + [[ "$item" == "$needle" ]] && return 0 + done + return 1 +} + +python_cmd=() +node_cmd=() + +resolve_python_cmd() { + if command -v python3 >/dev/null 2>&1 && python3 -c "import sys" >/dev/null 2>&1; then + python_cmd=(python3) + return 0 + fi + + if command -v py >/dev/null 2>&1 && py -3 -c "import sys" >/dev/null 2>&1; then + python_cmd=(py -3) + return 0 + fi + + if command -v python >/dev/null 2>&1 && python -c "import sys" >/dev/null 2>&1; then + python_cmd=(python) + return 0 + fi + + return 1 +} + +resolve_node_cmd() { + if command -v node >/dev/null 2>&1 && node -e "process.exit(0)" >/dev/null 2>&1; then + node_cmd=(node) + return 0 + fi + + if command -v node.exe >/dev/null 2>&1 && node.exe -e "process.exit(0)" >/dev/null 2>&1; then + node_cmd=(node.exe) + return 0 + fi + + if command -v powershell.exe >/dev/null 2>&1; then + local win_node + win_node="$(powershell.exe -NoProfile -Command "(Get-Command node -ErrorAction SilentlyContinue).Source" 2>/dev/null | tr -d '\r' | head -n 1)" + if [[ -n "$win_node" ]]; then + local unix_node="$win_node" + if command -v cygpath >/dev/null 2>&1; then + unix_node="$(cygpath -u "$win_node")" + else + unix_node="${unix_node//\\//}" + if [[ "$unix_node" =~ ^([A-Za-z]):/(.*)$ ]]; then + unix_node="/${BASH_REMATCH[1],,}/${BASH_REMATCH[2]}" + fi + fi + if [[ -x "$unix_node" ]]; then + node_cmd=("$unix_node") + return 0 + fi + fi + fi + + return 1 +} + +echo "Delano validation" +echo "=================" + +check_required_path ".project/projects" +check_required_path ".project/context" +check_required_path ".project/registry/linear-map.json" +check_required_path ".agents/scripts/pm" +check_required_path ".agents/rules" +check_required_path ".agents/hooks" +check_required_path ".agents/logs" +check_required_path ".agents/skills" + +if [[ -e ".claude" || -L ".claude" ]]; then + echo "✅ Compatibility runtime present: .claude" +else + echo "⚠️ Compatibility runtime missing: .claude (canonical .agents is sufficient)" + warnings=$((warnings + 1)) +fi + +if resolve_python_cmd; then + echo "✅ Python runtime: ${python_cmd[*]}" +else + echo "❌ Python runtime not found (tried: python3, py -3, python)" + errors=$((errors + 1)) +fi + +if resolve_node_cmd; then + if ! command -v node >/dev/null 2>&1; then + node() { + "${node_cmd[@]}" "$@" + } + fi + echo "Node runtime: ${node_cmd[*]}" +else + echo "Node runtime not found (tried: node, node.exe, PowerShell Get-Command node)" +fi + +# Required skill contracts +required_skills=( + discovery-skill + research-skill + prototype-skill + planning-skill + breakdown-skill + sync-skill + execution-skill + quality-skill + closeout-skill + learning-skill +) + +echo "" +echo "Required skills" +echo "---------------" +for skill in "${required_skills[@]}"; do + skill_dir=".agents/skills/$skill" + skill_file="$skill_dir/SKILL.md" + + if [[ -f "$skill_file" ]]; then + echo "✅ $skill_file" + else + echo "❌ Missing skill contract: $skill_file" + errors=$((errors + 1)) + continue + fi + + runbook="$skill_dir/references/runbook.md" + if [[ -f "$runbook" ]]; then + echo "✅ $runbook" + else + echo "❌ Missing skill runbook: $runbook" + errors=$((errors + 1)) + fi + + template_count=0 + if [[ -d "$skill_dir/templates" ]]; then + template_count=$(find "$skill_dir/templates" -maxdepth 1 -type f -name '*.md' | wc -l | tr -d ' ') + fi + + if [[ "$template_count" -ge 2 ]]; then + echo "✅ $skill_dir/templates ($template_count files)" + else + echo "❌ Skill needs at least 2 templates: $skill_dir/templates" + errors=$((errors + 1)) + fi + + if grep -q '^## Execution assets' "$skill_file"; then + echo "✅ $skill_file includes execution assets section" + else + echo "❌ $skill_file missing execution assets section" + errors=$((errors + 1)) + fi +done + +# Project contract validation +for project_dir in .project/projects/*; do + [[ -d "$project_dir" ]] || continue + [[ "$(basename "$project_dir")" == ".gitkeep" ]] && continue + + echo "" + echo "Project: $(basename "$project_dir")" + + for path in spec.md plan.md decisions.md tasks workstreams updates; do + if [[ ! -e "$project_dir/$path" ]]; then + echo " ❌ Missing $path" + errors=$((errors + 1)) + fi + done + + spec="$project_dir/spec.md" + if [[ -f "$spec" ]]; then + if ! has_frontmatter "$spec"; then + echo " ❌ spec.md missing frontmatter" + errors=$((errors + 1)) + fi + for key in name slug owner status created updated outcome uncertainty probe_required probe_status; do + val="$(fm_get "$spec" "$key")" + if [[ -z "$val" ]]; then + echo " ❌ spec.md missing key: $key" + errors=$((errors + 1)) + fi + done + for key in created updated; do + val="$(fm_get "$spec" "$key")" + if [[ -n "$val" ]] && ! is_iso_utc "$val"; then + echo " ❌ spec.md $key must be ISO8601 UTC" + errors=$((errors + 1)) + fi + done + fi + + plan="$project_dir/plan.md" + if [[ -f "$plan" ]]; then + if ! has_frontmatter "$plan"; then + echo " ❌ plan.md missing frontmatter" + errors=$((errors + 1)) + fi + for key in name status lead created updated linear_project_id risk_level spec_status_at_plan_time; do + val="$(fm_get "$plan" "$key")" + if [[ -z "$val" && "$key" != "linear_project_id" ]]; then + echo " ❌ plan.md missing key: $key" + errors=$((errors + 1)) + fi + done + for key in created updated; do + val="$(fm_get "$plan" "$key")" + if [[ -n "$val" ]] && ! is_iso_utc "$val"; then + echo " ❌ plan.md $key must be ISO8601 UTC" + errors=$((errors + 1)) + fi + done + fi + + workstream_ids=() + for workstream in "$project_dir"/workstreams/*.md; do + [[ -f "$workstream" ]] || continue + workstream_file="$(basename "$workstream" .md)" + if [[ "$workstream_file" =~ ^(WS-[A-Za-z0-9]+) ]]; then + workstream_ids+=("${BASH_REMATCH[1]}") + fi + done + + for task in "$project_dir"/tasks/*.md; do + [[ -f "$task" ]] || continue + if ! has_frontmatter "$task"; then + echo " ❌ $(basename "$task") missing frontmatter" + errors=$((errors + 1)) + continue + fi + for key in id name status workstream created updated linear_issue_id github_issue github_pr depends_on conflicts_with parallel priority estimate; do + val="$(fm_get "$task" "$key")" + if [[ -z "$val" && ! "$key" =~ ^(linear_issue_id|github_issue|github_pr|depends_on|conflicts_with)$ ]]; then + echo " ❌ $(basename "$task") missing key: $key" + errors=$((errors + 1)) + fi + done + task_workstream="$(fm_get "$task" "workstream")" + if [[ -n "$task_workstream" ]]; then + if [[ ! "$task_workstream" =~ ^WS-[A-Za-z0-9]+$ ]]; then + echo " ❌ $(basename "$task") workstream must use canonical form like WS-A" + errors=$((errors + 1)) + elif ! contains_value "$task_workstream" "${workstream_ids[@]}"; then + echo " ❌ $(basename "$task") workstream does not match a project workstream: $task_workstream" + errors=$((errors + 1)) + fi + fi + done + + # dependency cycle check for this project + if [[ ${#python_cmd[@]} -gt 0 ]]; then + "${python_cmd[@]}" - "$project_dir" <<'PY' || errors=$((errors + 1)) +import sys, re +from pathlib import Path + +project = Path(sys.argv[1]) +tasks = {} + +def parse_frontmatter(path: Path): + text = path.read_text(encoding='utf-8') + m = re.match(r'^---\n(.*?)\n---\n', text, re.S) + if not m: + return {} + data = {} + for line in m.group(1).splitlines(): + if ':' not in line: + continue + k, v = line.split(':', 1) + data[k.strip()] = v.strip() + return data + +for f in sorted((project / 'tasks').glob('*.md')): + meta = parse_frontmatter(f) + tid = meta.get('id') or f.stem + raw = meta.get('depends_on', '[]').strip() + deps = [] + if raw.startswith('[') and raw.endswith(']'): + inner = raw[1:-1].strip() + if inner: + deps = [x.strip().strip('"\'') for x in inner.split(',') if x.strip()] + tasks[tid] = deps + +visited = {} + +def dfs(node, stack): + state = visited.get(node, 0) + if state == 1: + cycle = ' -> '.join(stack + [node]) + raise RuntimeError(f'dependency cycle: {cycle}') + if state == 2: + return + visited[node] = 1 + for dep in tasks.get(node, []): + if dep in tasks: + dfs(dep, stack + [node]) + visited[node] = 2 + +for t in tasks: + dfs(t, []) +print(' [ok] dependency graph acyclic') +PY + fi + +done + +# Absolute path leakage check (documentation and contract files only) +path_tmp="$(mktemp)" +trap 'rm -f "$path_tmp"' EXIT + +compat_paths=() +if [[ -e .claude || -L .claude ]]; then + compat_paths+=(.claude) +fi + +if find .project .agents "${compat_paths[@]}" \ + -type f \ + \( -name '*.md' -o -name '*.json' -o -name '*.yaml' -o -name '*.yml' \) \ + -not -path '.agents/logs/*' \ + -not -path '.claude/logs/*' \ + -print0 | xargs -0 grep -nE '(/home/|/Users/|/mnt/[A-Za-z]/|[A-Za-z]:\\)' >"$path_tmp" 2>/dev/null; then + echo "" + echo "❌ Absolute path leakage found" + head -n 20 "$path_tmp" + errors=$((errors + 1)) +else + echo "" + echo "✅ No absolute path leakage in tracked docs and contracts" +fi + +if [[ -x .agents/scripts/check-log-safety.sh ]]; then + echo "" + if .agents/scripts/check-log-safety.sh; then + true + else + errors=$((errors + 1)) + fi +fi + +text_safety_check="" +if [[ -f .agents/scripts/check-text-safety.mjs ]]; then + text_safety_check=".agents/scripts/check-text-safety.mjs" +elif [[ -f scripts/check-text-safety.mjs ]]; then + text_safety_check="scripts/check-text-safety.mjs" +fi + +if [[ -n "$text_safety_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$text_safety_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for text safety check" + errors=$((errors + 1)) + fi +fi + +if [[ -f scripts/check-package-manifest-drift.mjs ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node scripts/check-package-manifest-drift.mjs; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for package/manifest drift check" + errors=$((errors + 1)) + fi +fi + +if [[ -f scripts/check-agent-entry-docs.mjs ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node scripts/check-agent-entry-docs.mjs; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for agent entry doc check" + errors=$((errors + 1)) + fi +fi + +if [[ -f scripts/check-artifact-scope.mjs ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node scripts/check-artifact-scope.mjs; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for artifact scope check" + errors=$((errors + 1)) + fi +fi + +artifact_schema_check="" +if [[ -f .agents/scripts/check-artifact-schemas.mjs ]]; then + artifact_schema_check=".agents/scripts/check-artifact-schemas.mjs" +elif [[ -f scripts/check-artifact-schemas.mjs ]]; then + artifact_schema_check="scripts/check-artifact-schemas.mjs" +fi + +if [[ -n "$artifact_schema_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$artifact_schema_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for artifact schema check" + errors=$((errors + 1)) + fi +fi + +if [[ -f scripts/check-adapter-manifests.mjs ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node scripts/check-adapter-manifests.mjs; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for adapter manifest check" + errors=$((errors + 1)) + fi +fi + +operating_modes_check="" +if [[ -f .agents/scripts/check-operating-modes.mjs ]]; then + operating_modes_check=".agents/scripts/check-operating-modes.mjs" +elif [[ -f scripts/check-operating-modes.mjs ]]; then + operating_modes_check="scripts/check-operating-modes.mjs" +fi + +if [[ -n "$operating_modes_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$operating_modes_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for operating modes check" + errors=$((errors + 1)) + fi +fi + +status_transition_check="" +if [[ -f .agents/scripts/check-status-transitions.mjs ]]; then + status_transition_check=".agents/scripts/check-status-transitions.mjs" +elif [[ -f scripts/check-status-transitions.mjs ]]; then + status_transition_check="scripts/check-status-transitions.mjs" +fi + +if [[ -n "$status_transition_check" ]]; then + echo "" + echo "Project lifecycle and status transition check" + echo "---------------------------------------------" + if command -v node >/dev/null 2>&1; then + if node "$status_transition_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for status transition check" + errors=$((errors + 1)) + fi +fi + +evidence_map_check="" +if [[ -f .agents/scripts/check-evidence-map.mjs ]]; then + evidence_map_check=".agents/scripts/check-evidence-map.mjs" +elif [[ -f scripts/check-evidence-map.mjs ]]; then + evidence_map_check="scripts/check-evidence-map.mjs" +fi + +if [[ -n "$evidence_map_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$evidence_map_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for evidence map check" + errors=$((errors + 1)) + fi +fi + +strict_fixtures_check="" +if [[ -f .agents/scripts/check-strict-fixtures.mjs ]]; then + strict_fixtures_check=".agents/scripts/check-strict-fixtures.mjs" +elif [[ -f scripts/check-strict-fixtures.mjs ]]; then + strict_fixtures_check="scripts/check-strict-fixtures.mjs" +fi + +if [[ -n "$strict_fixtures_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$strict_fixtures_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for strict fixtures check" + errors=$((errors + 1)) + fi +fi + +sync_schema_check="" +if [[ -f .agents/scripts/check-sync-schemas.mjs ]]; then + sync_schema_check=".agents/scripts/check-sync-schemas.mjs" +elif [[ -f scripts/check-sync-schemas.mjs ]]; then + sync_schema_check="scripts/check-sync-schemas.mjs" +fi + +if [[ -n "$sync_schema_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$sync_schema_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for sync schema check" + errors=$((errors + 1)) + fi +fi + +local_sync_map_check="" +if [[ -f .agents/scripts/check-local-sync-map.mjs ]]; then + local_sync_map_check=".agents/scripts/check-local-sync-map.mjs" +elif [[ -f scripts/check-local-sync-map.mjs ]]; then + local_sync_map_check="scripts/check-local-sync-map.mjs" +fi + +if [[ -n "$local_sync_map_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$local_sync_map_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for local sync map check" + errors=$((errors + 1)) + fi +fi + +github_sync_check="" +if [[ -f .agents/scripts/check-github-sync.mjs ]]; then + github_sync_check=".agents/scripts/check-github-sync.mjs" +elif [[ -f scripts/check-github-sync.mjs ]]; then + github_sync_check="scripts/check-github-sync.mjs" +fi + +if [[ -n "$github_sync_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$github_sync_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for GitHub sync inspection" + errors=$((errors + 1)) + fi +fi + +local_sync_map_check="" +if [[ -f .agents/scripts/read-local-sync-map.mjs ]]; then + local_sync_map_check=".agents/scripts/read-local-sync-map.mjs" +elif [[ -f scripts/read-local-sync-map.mjs ]]; then + local_sync_map_check="scripts/read-local-sync-map.mjs" +fi + +if [[ -n "$local_sync_map_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$local_sync_map_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for local sync map check" + errors=$((errors + 1)) + fi +fi + +github_sync_check="" +if [[ -f .agents/scripts/inspect-github-sync.mjs ]]; then + github_sync_check=".agents/scripts/inspect-github-sync.mjs" +elif [[ -f scripts/inspect-github-sync.mjs ]]; then + github_sync_check="scripts/inspect-github-sync.mjs" +fi + +if [[ -n "$github_sync_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$github_sync_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for GitHub sync inspection" + errors=$((errors + 1)) + fi +fi + +github_status_check="" +if [[ -f .agents/scripts/check-github-status-inspection.mjs ]]; then + github_status_check=".agents/scripts/check-github-status-inspection.mjs" +elif [[ -f scripts/check-github-status-inspection.mjs ]]; then + github_status_check="scripts/check-github-status-inspection.mjs" +fi + +if [[ -n "$github_status_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$github_status_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for GitHub status inspection" + errors=$((errors + 1)) + fi +fi + +linear_issue_check="" +if [[ -f .agents/scripts/check-linear-issue-inspection.mjs ]]; then + linear_issue_check=".agents/scripts/check-linear-issue-inspection.mjs" +elif [[ -f scripts/check-linear-issue-inspection.mjs ]]; then + linear_issue_check="scripts/check-linear-issue-inspection.mjs" +fi + +if [[ -n "$linear_issue_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$linear_issue_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for Linear issue inspection" + errors=$((errors + 1)) + fi +fi + +drift_report_check="" +if [[ -f .agents/scripts/build-drift-report.mjs ]]; then + drift_report_check=".agents/scripts/build-drift-report.mjs" +elif [[ -f scripts/build-drift-report.mjs ]]; then + drift_report_check="scripts/build-drift-report.mjs" +fi + +if [[ -n "$drift_report_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$drift_report_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for dry-run drift report" + errors=$((errors + 1)) + fi +fi + +repair_plan_check="" +if [[ -f .agents/scripts/plan-sync-repairs.mjs ]]; then + repair_plan_check=".agents/scripts/plan-sync-repairs.mjs" +elif [[ -f scripts/plan-sync-repairs.mjs ]]; then + repair_plan_check="scripts/plan-sync-repairs.mjs" +fi + +if [[ -n "$repair_plan_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$repair_plan_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for sync repair planning" + errors=$((errors + 1)) + fi +fi + +lease_contract_check="" +if [[ -f .agents/scripts/check-lease-contracts.mjs ]]; then + lease_contract_check=".agents/scripts/check-lease-contracts.mjs" +elif [[ -f scripts/check-lease-contracts.mjs ]]; then + lease_contract_check="scripts/check-lease-contracts.mjs" +fi + +if [[ -n "$lease_contract_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$lease_contract_check"; then true; else errors=$((errors + 1)); fi + else + echo "❌ Node runtime not found for lease contract check"; errors=$((errors + 1)) + fi +fi + +lease_manager_check="" +if [[ -f .agents/scripts/lease-manager.mjs ]]; then + lease_manager_check=".agents/scripts/lease-manager.mjs" +elif [[ -f scripts/lease-manager.mjs ]]; then + lease_manager_check="scripts/lease-manager.mjs" +fi + +if [[ -n "$lease_manager_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$lease_manager_check" self-test; then true; else errors=$((errors + 1)); fi + else + echo "❌ Node runtime not found for lease manager check"; errors=$((errors + 1)) + fi +fi + +lease_conflict_check="" +if [[ -f .agents/scripts/check-lease-conflicts.mjs ]]; then + lease_conflict_check=".agents/scripts/check-lease-conflicts.mjs" +elif [[ -f scripts/check-lease-conflicts.mjs ]]; then + lease_conflict_check="scripts/check-lease-conflicts.mjs" +fi + +if [[ -n "$lease_conflict_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$lease_conflict_check" --zone scripts/lease-manager.mjs --mode exclusive; then true; else errors=$((errors + 1)); fi + else + echo "❌ Node runtime not found for lease conflict check"; errors=$((errors + 1)) + fi +fi + +next_task_check="" +if [[ -f .agents/scripts/select-next-task.mjs ]]; then + next_task_check=".agents/scripts/select-next-task.mjs" +elif [[ -f scripts/select-next-task.mjs ]]; then + next_task_check="scripts/select-next-task.mjs" +fi + +if [[ -n "$next_task_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$next_task_check" --project delano-multi-agent-execution --stream default; then true; else errors=$((errors + 1)); fi + else + echo "❌ Node runtime not found for next task selection check"; errors=$((errors + 1)) + fi +fi + +worktree_health_check="" +if [[ -f .agents/scripts/check-worktree-health.mjs ]]; then + worktree_health_check=".agents/scripts/check-worktree-health.mjs" +elif [[ -f scripts/check-worktree-health.mjs ]]; then + worktree_health_check="scripts/check-worktree-health.mjs" +fi + +if [[ -n "$worktree_health_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$worktree_health_check"; then true; else errors=$((errors + 1)); fi + else + echo "❌ Node runtime not found for worktree health check"; errors=$((errors + 1)) + fi +fi + +delivery_metrics_check="" +if [[ -f .agents/scripts/check-delivery-metric-events.mjs ]]; then + delivery_metrics_check=".agents/scripts/check-delivery-metric-events.mjs" +elif [[ -f scripts/check-delivery-metric-events.mjs ]]; then + delivery_metrics_check="scripts/check-delivery-metric-events.mjs" +fi + +if [[ -n "$delivery_metrics_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$delivery_metrics_check"; then true; else errors=$((errors + 1)); fi + else + echo "❌ Node runtime not found for delivery metrics check"; errors=$((errors + 1)) + fi +fi + +handoff_summary_check="" +if [[ -f .agents/scripts/check-handoff-summaries.mjs ]]; then + handoff_summary_check=".agents/scripts/check-handoff-summaries.mjs" +elif [[ -f scripts/check-handoff-summaries.mjs ]]; then + handoff_summary_check="scripts/check-handoff-summaries.mjs" +fi + +if [[ -n "$handoff_summary_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$handoff_summary_check" --self-test; then true; else errors=$((errors + 1)); fi + else + echo "❌ Node runtime not found for handoff summary check"; errors=$((errors + 1)) + fi +fi + +delivery_metrics_check="" +if [[ -f .agents/scripts/check-delivery-metrics.mjs ]]; then + delivery_metrics_check=".agents/scripts/check-delivery-metrics.mjs" +elif [[ -f scripts/check-delivery-metrics.mjs ]]; then + delivery_metrics_check="scripts/check-delivery-metrics.mjs" +fi + +if [[ -n "$delivery_metrics_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$delivery_metrics_check"; then + true + else + errors=$((errors + 1)) + fi + else + echo "❌ Node runtime not found for delivery metric event check" + errors=$((errors + 1)) + fi +fi + +project_metrics_check="" +if [[ -f .agents/scripts/summarize-project-metrics.mjs ]]; then + project_metrics_check=".agents/scripts/summarize-project-metrics.mjs" +elif [[ -f scripts/summarize-project-metrics.mjs ]]; then + project_metrics_check="scripts/summarize-project-metrics.mjs" +fi + +if [[ -n "$project_metrics_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$project_metrics_check" --json >/dev/null; then true; else errors=$((errors + 1)); fi + else + echo "❌ Node runtime not found for project metrics summary"; errors=$((errors + 1)) + fi +fi + +context_audit_check="" +if [[ -f .agents/scripts/audit-context-scoring.mjs ]]; then + context_audit_check=".agents/scripts/audit-context-scoring.mjs" +elif [[ -f scripts/audit-context-scoring.mjs ]]; then + context_audit_check="scripts/audit-context-scoring.mjs" +fi + +if [[ -n "$context_audit_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$context_audit_check"; then true; else errors=$((errors + 1)); fi + else + echo "❌ Node runtime not found for context audit scoring"; errors=$((errors + 1)) + fi +fi + +skill_eval_check="" +if [[ -f .agents/scripts/check-skill-output-evals.mjs ]]; then + skill_eval_check=".agents/scripts/check-skill-output-evals.mjs" +elif [[ -f scripts/check-skill-output-evals.mjs ]]; then + skill_eval_check="scripts/check-skill-output-evals.mjs" +fi + +if [[ -n "$skill_eval_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$skill_eval_check"; then true; else errors=$((errors + 1)); fi + else + echo "❌ Node runtime not found for skill output evals"; errors=$((errors + 1)) + fi +fi + +closeout_learning_check="" +if [[ -f .agents/scripts/propose-closeout-learning.mjs ]]; then + closeout_learning_check=".agents/scripts/propose-closeout-learning.mjs" +elif [[ -f scripts/propose-closeout-learning.mjs ]]; then + closeout_learning_check="scripts/propose-closeout-learning.mjs" +fi + +if [[ -n "$closeout_learning_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$closeout_learning_check" --json >/dev/null; then true; else errors=$((errors + 1)); fi + else + echo "❌ Node runtime not found for closeout learning proposal"; errors=$((errors + 1)) + fi +fi + +closeout_learning_proposal_check="" +if [[ -f .agents/scripts/check-closeout-learning-proposals.mjs ]]; then + closeout_learning_proposal_check=".agents/scripts/check-closeout-learning-proposals.mjs" +elif [[ -f scripts/check-closeout-learning-proposals.mjs ]]; then + closeout_learning_proposal_check="scripts/check-closeout-learning-proposals.mjs" +fi + +if [[ -n "$closeout_learning_proposal_check" ]]; then + echo "" + if command -v node >/dev/null 2>&1; then + if node "$closeout_learning_proposal_check"; then true; else errors=$((errors + 1)); fi + else + echo "❌ Node runtime not found for closeout learning proposal check"; errors=$((errors + 1)) + fi +fi + +echo "" +echo "Summary" +echo "-------" +echo "Errors: $errors" +echo "Warnings: $warnings" + +if [[ $errors -gt 0 ]]; then + exit 1 +fi diff --git a/.agents/scripts/propose-closeout-learning.mjs b/.agents/scripts/propose-closeout-learning.mjs new file mode 100644 index 0000000..0a0af8b --- /dev/null +++ b/.agents/scripts/propose-closeout-learning.mjs @@ -0,0 +1,20 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const project = readOption("--project") || "delano-learning-loop"; +const metricsPath = readOption("--metrics") || path.join(repoRoot, ".agents", "metrics", "delivery-events.jsonl"); +const metrics = readEvents(metricsPath).filter((event)=>event.project === project || !event.project); +const proposal = { + schema_version: 1, + mode: "dry-run-proposal", + project, + apply_posture: "proposal-only-no-mutation", + summary: { event_count: metrics.length, recommendation_count: 1, privacy: "summary-only" }, + recommendations: [{ id: "LP-001", type: "closeout-learning", summary: metrics.length ? "Review summarized delivery metrics during closeout." : "Capture at least one delivery metric event during future closeouts.", evidence: ["scripts/summarize-project-metrics.mjs", ".agents/logs/delivery-metrics.md"] }] +}; +if (process.argv.includes("--json")) console.log(JSON.stringify(proposal,null,2)); else console.log(`Closeout learning proposal produced ${proposal.summary.recommendation_count} recommendation(s) for ${project}.`); +function readEvents(filePath){ if(!existsSync(filePath)) return []; return readFileSync(filePath,"utf8").split(/\r?\n/).filter(Boolean).map(line=>JSON.parse(line)); } +function readOption(name){ const i=process.argv.indexOf(name); return i===-1?"":process.argv[i+1]; } +function resolveRepoRoot(startDir){ for(const c of [path.resolve(startDir,".."),path.resolve(startDir,"..","..")]) if(existsSync(path.join(c,".agents"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/query-log.sh b/.agents/scripts/query-log.sh new file mode 100644 index 0000000..6c225c0 --- /dev/null +++ b/.agents/scripts/query-log.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +type_filter="" +actor_filter="" +since_filter="" +last_n="" +pretty=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --type) type_filter="$2"; shift 2 ;; + --actor) actor_filter="$2"; shift 2 ;; + --since) since_filter="$2"; shift 2 ;; + --last) last_n="$2"; shift 2 ;; + --pretty) pretty=true; shift ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +LOG_FILE=".agents/logs/changes.jsonl" +[[ -f "$LOG_FILE" ]] || { echo "No log file: $LOG_FILE"; exit 0; } + +node - "$LOG_FILE" "$type_filter" "$actor_filter" "$since_filter" "$last_n" "$pretty" <<'NODE' +const fs = require('fs'); + +const [logFile, typeFilter, actorFilter, sinceFilter, lastNRaw, prettyRaw] = process.argv.slice(2); +const pretty = prettyRaw === 'true'; +const lines = fs.readFileSync(logFile, 'utf8').split('\n').filter(Boolean); +const parsed = []; +for (const line of lines) { + try { parsed.push(JSON.parse(line)); } catch {} +} + +let out = parsed.filter(e => { + if (typeFilter && e.type !== typeFilter) return false; + if (actorFilter && e.actor !== actorFilter) return false; + if (sinceFilter && (e.timestamp || '') < sinceFilter) return false; + return true; +}); + +const lastN = Number(lastNRaw || 0); +if (Number.isFinite(lastN) && lastN > 0) { + out = out.slice(-lastN); +} + +for (const row of out) { + if (pretty) { + console.log(JSON.stringify(row, null, 2)); + } else { + console.log(JSON.stringify(row)); + } +} +NODE diff --git a/.agents/scripts/read-local-sync-map.mjs b/.agents/scripts/read-local-sync-map.mjs new file mode 100644 index 0000000..8000397 --- /dev/null +++ b/.agents/scripts/read-local-sync-map.mjs @@ -0,0 +1,135 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); + +export function readLocalSyncMap(root = repoRoot) { + const projectsRoot = path.join(root, ".project", "projects"); + const registryPath = path.join(root, ".project", "registry", "linear-map.json"); + const registry = readOptionalJson(registryPath) || { projects: {}, tasks: {} }; + const projects = []; + + for (const projectDir of listProjectDirs(projectsRoot)) { + const slug = path.basename(projectDir); + const projectRegistry = registry.projects?.[slug] || {}; + const tasks = []; + const tasksDir = path.join(projectDir, "tasks"); + if (existsSync(tasksDir)) { + for (const taskFile of readdirSync(tasksDir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => path.join(tasksDir, entry.name)).sort()) { + const text = readFileSync(taskFile, "utf8"); + const fm = parseFrontmatter(text); + const registryKey = `${slug}/${fm.id || path.basename(taskFile, ".md")}`; + const taskRegistry = registry.tasks?.[registryKey] || registry.tasks?.[fm.id] || {}; + tasks.push({ + local_id: fm.id || path.basename(taskFile, ".md"), + name: fm.name || titleFromMarkdown(text) || path.basename(taskFile, ".md"), + status: fm.status || "unknown", + workstream: fm.workstream || "", + local_path: toRepoPath(root, taskFile), + depends_on: parseList(fm.depends_on || "[]"), + linear_issue_id: emptyToUndefined(fm.linear_issue_id) || taskRegistry.linear_issue_id, + github_issue: emptyToUndefined(fm.github_issue) || taskRegistry.github_issue, + github_pr: emptyToUndefined(fm.github_pr) || taskRegistry.github_pr + }); + } + } + projects.push({ + slug, + local_path: toRepoPath(root, projectDir), + linear_project_id: projectRegistry.linear_project_id, + github_repo: projectRegistry.github_repo, + tasks + }); + } + + return { schema_version: 1, source: "local", projects }; +} + +export function validateLocalSyncMap(syncMap) { + const errors = []; + const seenProjects = new Set(); + for (const project of syncMap.projects || []) { + if (seenProjects.has(project.slug)) errors.push(`duplicate project slug: ${project.slug}`); + seenProjects.add(project.slug); + if (!/^\.project\/projects\/[^/]+$/.test(project.local_path || "")) errors.push(`invalid project local_path for ${project.slug}: ${project.local_path}`); + const seenTasks = new Set(); + for (const task of project.tasks || []) { + if (!/^T-[0-9]{3}$/.test(task.local_id || "")) errors.push(`${project.slug} has invalid task id: ${task.local_id}`); + if (seenTasks.has(task.local_id)) errors.push(`${project.slug} has duplicate task id: ${task.local_id}`); + seenTasks.add(task.local_id); + for (const dependency of task.depends_on || []) { + if (!seenTasks.has(dependency) && !(project.tasks || []).some((candidate) => candidate.local_id === dependency)) { + errors.push(`${project.slug}/${task.local_id} depends on missing local task ${dependency}`); + } + } + if (task.github_issue && !isUrlOrNumber(task.github_issue)) errors.push(`${project.slug}/${task.local_id} has invalid github_issue: ${task.github_issue}`); + if (task.github_pr && !isUrlOrNumber(task.github_pr)) errors.push(`${project.slug}/${task.local_id} has invalid github_pr: ${task.github_pr}`); + } + } + return errors; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const syncMap = readLocalSyncMap(repoRoot); + const errors = validateLocalSyncMap(syncMap); + if (process.argv.includes("--json")) { + console.log(JSON.stringify(syncMap, null, 2)); + } + if (errors.length > 0) { + console.error("Local sync map validation failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); + } + if (!process.argv.includes("--json")) { + const taskCount = syncMap.projects.reduce((sum, project) => sum + project.tasks.length, 0); + console.log(`Local sync map check passed for ${syncMap.projects.length} project(s) and ${taskCount} task(s).`); + } +} + +function listProjectDirs(projectsRoot) { + if (!existsSync(projectsRoot)) return []; + return readdirSync(projectsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(projectsRoot, entry.name)).sort(); +} +function parseFrontmatter(text) { + const match = text.match(/^---\n([\s\S]*?)\n---\n/); + if (!match) return {}; + const result = {}; + for (const line of match[1].split("\n")) { + const index = line.indexOf(":"); + if (index === -1) continue; + result[line.slice(0, index).trim()] = line.slice(index + 1).trim(); + } + return result; +} +function parseList(raw) { + const value = String(raw || "").trim(); + if (!value || value === "[]") return []; + if (value.startsWith("[") && value.endsWith("]")) return value.slice(1, -1).split(",").map((item) => item.trim().replace(/^['\"]|['\"]$/g, "")).filter(Boolean); + return [value.replace(/^['\"]|['\"]$/g, "")].filter(Boolean); +} +function titleFromMarkdown(text) { + const match = text.match(/^#\s+(.+)$/m); + return match ? match[1].replace(/^Task:\s*/, "").trim() : ""; +} +function emptyToUndefined(value) { + const trimmed = String(value || "").trim(); + return trimmed ? trimmed : undefined; +} +function isUrlOrNumber(value) { + return /^https?:\/\//.test(value) || /^#?[0-9]+$/.test(value); +} +function readOptionalJson(filePath) { + if (!existsSync(filePath)) return null; + return JSON.parse(readFileSync(filePath, "utf8")); +} +function toRepoPath(root, filePath) { + return path.relative(root, filePath).split(path.sep).join("/"); +} +function resolveRepoRoot(startDir) { + const candidates = [path.resolve(startDir, ".."), path.resolve(startDir, "..", "..")]; + for (const candidate of candidates) if (existsSync(path.join(candidate, ".project")) && existsSync(path.join(candidate, ".agents"))) return candidate; + return path.resolve(startDir, ".."); +} diff --git a/.agents/scripts/select-next-task.mjs b/.agents/scripts/select-next-task.mjs new file mode 100644 index 0000000..cdfe122 --- /dev/null +++ b/.agents/scripts/select-next-task.mjs @@ -0,0 +1,23 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const projectSlug = readOption("--project") || "delano-multi-agent-execution"; +const stream = readOption("--stream") || "default"; +const tasksDir = path.join(repoRoot, ".project", "projects", projectSlug, "tasks"); +const leases = readLeases(readOption("--leases") || path.join(repoRoot, ".agents", "leases", "active-leases.json")); +const taskFiles = existsSync(tasksDir) ? readdirSync(tasksDir).filter((file)=>file.endsWith(".md")) : []; +const ready = taskFiles.map((file)=>readTask(path.join(tasksDir,file))).filter((task)=>task.status === "ready"); +const activeZones = new Set(leases.filter((l)=>l.status === "active" && new Date(l.expires_at).getTime() > Date.now()).flatMap((l)=>l.conflict_zones || [])); +const candidates = ready.map((task)=>({ ...task, stream, blocked_by_active_zone: task.conflicts_with.some((zone)=>activeZones.has(zone)) })).filter((task)=>!task.blocked_by_active_zone); +const selected = candidates[0] || null; +const result = { schema_version: 1, project: projectSlug, stream, ready_count: ready.length, candidate_count: candidates.length, selected: selected && { id: selected.id, file: selected.file, priority: selected.priority } }; +if (process.argv.includes("--json")) console.log(JSON.stringify(result, null, 2)); else console.log(selected ? `Selected ${selected.id} for ${stream}.` : `No unleased ready task for ${stream}.`); +export function readTask(filePath) { const text=readFileSync(filePath,"utf8"); return { file:path.basename(filePath), id: front(text,"id"), status: front(text,"status"), priority: front(text,"priority"), conflicts_with: list(front(text,"conflicts_with")) }; } +function front(text,key){ const m=text.match(new RegExp(`^${key}:\\s*(.*)$`,"m")); return m?m[1].trim():""; } +function list(v){ const m=v.match(/^\[(.*)\]$/); return m?m[1].split(",").map(x=>x.trim()).filter(Boolean):[]; } +function readLeases(filePath){ if(!existsSync(filePath)) return []; return JSON.parse(readFileSync(filePath,"utf8")).leases || []; } +function readOption(name){ const i=process.argv.indexOf(name); return i===-1?"":process.argv[i+1]; } +function resolveRepoRoot(startDir){ for(const c of [path.resolve(startDir,".."),path.resolve(startDir,"..","..")]) if(existsSync(path.join(c,".project"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/summarize-project-metrics.mjs b/.agents/scripts/summarize-project-metrics.mjs new file mode 100644 index 0000000..b59fcd8 --- /dev/null +++ b/.agents/scripts/summarize-project-metrics.mjs @@ -0,0 +1,15 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const repoRoot = resolveRepoRoot(__dirname); +const eventsPath = readOption("--events") || path.join(repoRoot, ".agents", "metrics", "delivery-events.jsonl"); +const project = readOption("--project") || "all"; +const events = readEvents(eventsPath).filter((e)=>project === "all" || e.project === project); +const byType = Object.create(null); +for (const e of events) byType[e.event_type] = (byType[e.event_type] || 0) + 1; +const summary = { schema_version: 1, project, event_count: events.length, by_type: byType, privacy: "summary-only" }; +if (process.argv.includes("--json")) console.log(JSON.stringify(summary, null, 2)); else console.log(`Project metrics summary: ${events.length} event(s), privacy=summary-only.`); +function readEvents(filePath){ if(!existsSync(filePath)) return []; return readFileSync(filePath,"utf8").split(/\r?\n/).filter(Boolean).map((line)=>JSON.parse(line)); } +function readOption(name){ const i=process.argv.indexOf(name); return i===-1?"":process.argv[i+1]; } +function resolveRepoRoot(startDir){ for(const c of [path.resolve(startDir,".."),path.resolve(startDir,"..","..")]) if(existsSync(path.join(c,".agents"))) return c; return path.resolve(startDir,".."); } diff --git a/.agents/scripts/test-and-log.sh b/.agents/scripts/test-and-log.sh new file mode 100644 index 0000000..c238438 --- /dev/null +++ b/.agents/scripts/test-and-log.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -eq 0 ]]; then + echo "Usage: $0 <test-command...>" + exit 1 +fi + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +mkdir -p .agents/logs/tests +run_id="$(date -u +"%Y%m%dT%H%M%SZ")" +log_file=".agents/logs/tests/$run_id.log" + +set +e +"$@" 2>&1 | tee "$log_file" +exit_code=${PIPESTATUS[0]} +set -e + +timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +printf '{"timestamp":"%s","command":"%s","exit_code":%s,"log_file":"%s"}\n' \ + "$timestamp" "$*" "$exit_code" "$log_file" >> .agents/logs/test-runs.jsonl + +.agents/scripts/log-event.sh test_run system --command "$*" --exit "$exit_code" --log "$log_file" >/dev/null || true + +echo "Saved test log: $log_file" +exit $exit_code diff --git a/.agents/skills/.gitkeep b/.agents/skills/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 0000000..3d6c88d --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,30 @@ +# Delano Skills + +Handbook-aligned skill contracts. + +Core workflow skills: + +- `discovery-skill` +- `research-skill` +- `prototype-skill` +- `planning-skill` +- `breakdown-skill` +- `sync-skill` +- `execution-skill` +- `quality-skill` +- `closeout-skill` +- `learning-skill` + +Utility skills: + +- `manage-context` +- `onboarding` + +Each skill defines: +- intent and trigger context +- required inputs +- output schema +- quality checks +- failure behavior +- allowed side effects +- script hooks diff --git a/.agents/skills/breakdown-skill/SKILL.md b/.agents/skills/breakdown-skill/SKILL.md new file mode 100644 index 0000000..10429ba --- /dev/null +++ b/.agents/skills/breakdown-skill/SKILL.md @@ -0,0 +1,40 @@ +--- +name: breakdown-skill +description: Decompose an approved plan into atomic tasks with dependencies and acceptance criteria. Use when planning is complete and execution must be prepared. +--- + +# breakdown-skill + +## Trigger context +- plan is complete and ready for decomposition + +## Required inputs +- spec_path +- plan_path +- workstream_files + +## Output schema +- task_files +- dependency_graph + +## Quality checks +- acceptance criteria are binary +- estimate present per task +- dependency graph acyclic + +## Failure behavior +- stop on circular dependency +- return ambiguity report + +## Allowed side effects +- create/update `.project/projects/<slug>/tasks/*.md` + +## Script hooks +- `bash .agents/scripts/pm/validate.sh` +- `bash .agents/scripts/pm/next.sh` +- `bash .agents/scripts/pm/blocked.sh` + +## Execution assets +- `references/runbook.md` +- `templates/task-batch-summary.md` +- `templates/ambiguity-report.md` diff --git a/.agents/skills/breakdown-skill/references/runbook.md b/.agents/skills/breakdown-skill/references/runbook.md new file mode 100644 index 0000000..b43e7c6 --- /dev/null +++ b/.agents/skills/breakdown-skill/references/runbook.md @@ -0,0 +1,16 @@ +# Breakdown Runbook + +1. Read `plan.md` and `workstreams/*.md`. +2. Generate atomic tasks from `.project/templates/task.md`. +3. Add binary acceptance criteria per task. +4. Add dependencies and estimate/priority fields. +5. Run sequencing checks: + - `bash .agents/scripts/pm/next.sh --all` + - `bash .agents/scripts/pm/blocked.sh` +6. Validate: + - `bash .agents/scripts/pm/validate.sh` + +Exit gate: +- Tasks are atomic +- Dependencies are acyclic +- Ready tasks are execution-safe diff --git a/.agents/skills/breakdown-skill/templates/ambiguity-report.md b/.agents/skills/breakdown-skill/templates/ambiguity-report.md new file mode 100644 index 0000000..71ee486 --- /dev/null +++ b/.agents/skills/breakdown-skill/templates/ambiguity-report.md @@ -0,0 +1,11 @@ +# Breakdown Ambiguity Report + +## Ambiguous Requirement + +## Why It Blocks Decomposition + +## Clarification Needed + +## Impacted Workstreams + +## Suggested Resolution diff --git a/.agents/skills/breakdown-skill/templates/task-batch-summary.md b/.agents/skills/breakdown-skill/templates/task-batch-summary.md new file mode 100644 index 0000000..9054d44 --- /dev/null +++ b/.agents/skills/breakdown-skill/templates/task-batch-summary.md @@ -0,0 +1,11 @@ +# Task Batch Summary + +## Generated Tasks +- T-001: +- T-002: + +## Dependency Notes + +## Ambiguities Found + +## Recommended Start Order diff --git a/.agents/skills/closeout-skill/SKILL.md b/.agents/skills/closeout-skill/SKILL.md new file mode 100644 index 0000000..d3e85e8 --- /dev/null +++ b/.agents/skills/closeout-skill/SKILL.md @@ -0,0 +1,45 @@ +--- +name: closeout-skill +description: Close the delivery loop and capture completion evidence, status updates, and handoff artifacts. Use after quality gates pass. +--- + +# closeout-skill + +## Trigger context +- quality gates passed for closure scope + +## Required inputs +- project_slug +- completed_task_ids +- outcome_review + +## Output schema +- closure update +- completion summary +- updated status in contracts/registry +- learning proposals for any rule, skill, schema, or fixture changes discovered during closeout + +## Quality checks +- required tasks resolved +- evidence package complete +- outcome review captured +- learning proposals are reviewed before adoption + +## Failure behavior +- block closure when evidence is incomplete +- return missing-evidence list + +## Allowed side effects +- update project/task statuses +- append completion summary and release evidence + +## Script hooks +- `bash .agents/scripts/pm/status.sh` +- `bash .agents/scripts/query-log.sh --last 50` +- `bash .agents/scripts/pm/validate.sh` + +## Execution assets +- `references/runbook.md` +- `templates/outcome-review.md` +- `templates/closure-checklist.md` +- `templates/learning-proposal.md` diff --git a/.agents/skills/closeout-skill/references/runbook.md b/.agents/skills/closeout-skill/references/runbook.md new file mode 100644 index 0000000..3ca7ca1 --- /dev/null +++ b/.agents/skills/closeout-skill/references/runbook.md @@ -0,0 +1,19 @@ +# Closeout Runbook + +1. Confirm all required tasks are in terminal state. +2. Ensure quality evidence package is complete. +3. Write completion summary from template. +4. Draft a learning proposal for any proposed rule, skill, schema, or fixture update. +5. Keep learning proposals in `proposed` state until reviewed and explicitly accepted. +6. Update project status and mapping registry. +7. Review event log: + - `bash .agents/scripts/query-log.sh --last 100` +6. Validate: + - `bash .agents/scripts/pm/status.sh` + - `bash .agents/scripts/pm/validate.sh` + +Exit gate: +- Outcome review captured +- Learning proposals reviewed before adoption +- Evidence complete +- Delivery state closed cleanly diff --git a/.agents/skills/closeout-skill/templates/closure-checklist.md b/.agents/skills/closeout-skill/templates/closure-checklist.md new file mode 100644 index 0000000..e4e520c --- /dev/null +++ b/.agents/skills/closeout-skill/templates/closure-checklist.md @@ -0,0 +1,9 @@ +# Closure Checklist + +- [ ] Required tasks resolved +- [ ] Quality gates passed +- [ ] Evidence package complete +- [ ] Registry/state updated +- [ ] Learning proposals drafted for rule, skill, schema, or fixture changes +- [ ] Learning proposals reviewed before adoption +- [ ] Retrospective scheduled diff --git a/.agents/skills/closeout-skill/templates/learning-proposal.md b/.agents/skills/closeout-skill/templates/learning-proposal.md new file mode 100644 index 0000000..b82c9e5 --- /dev/null +++ b/.agents/skills/closeout-skill/templates/learning-proposal.md @@ -0,0 +1,21 @@ +# Closeout Learning Proposal + +## Proposal Type +rule | skill | schema | fixture + +## Title + +## Rationale +What happened, what should change, and why the change is worth reviewing. + +## Target Paths +- repo/relative/path + +## Evidence +- command, task, fixture, or local event summary + +## Review Gate +Required before adoption. Do not apply the proposed rule, skill, schema, or fixture change until it has been reviewed and explicitly accepted. + +## Adoption Status +proposed diff --git a/.agents/skills/closeout-skill/templates/learning-proposals.md b/.agents/skills/closeout-skill/templates/learning-proposals.md new file mode 100644 index 0000000..f3ce421 --- /dev/null +++ b/.agents/skills/closeout-skill/templates/learning-proposals.md @@ -0,0 +1,25 @@ +# Closeout Learning Proposals + +Use this during project closeout to propose reusable runtime changes. Proposals are review-first: do not silently adopt changes to shared rules, skills, schemas, or fixtures. + +## Proposal Summary + +- Project: +- Source closeout/update: +- Reviewer: +- Review status: pending + +## Proposed Changes + +| Target type | Target path | Change summary | Evidence | Adoption state | +| --- | --- | --- | --- | --- | +| rule | `.agents/rules/example.md` | Replace with the observed improvement. | Link task/update evidence. | proposed | +| skill | `.agents/skills/example-skill/SKILL.md` | Replace with the observed improvement. | Link task/update evidence. | proposed | +| schema | `.agents/schemas/example.schema.json` | Replace with the observed improvement. | Link task/update evidence. | proposed | +| fixture | `.agents/validation-fixtures/example.json` | Replace with the observed improvement. | Link task/update evidence. | proposed | + +## Review Gate + +- [ ] Every proposal cites observed evidence. +- [ ] No proposal is adopted before review status is approved. +- [ ] Rejected proposals retain the reason for future context. diff --git a/.agents/skills/closeout-skill/templates/outcome-review.md b/.agents/skills/closeout-skill/templates/outcome-review.md new file mode 100644 index 0000000..6135afa --- /dev/null +++ b/.agents/skills/closeout-skill/templates/outcome-review.md @@ -0,0 +1,11 @@ +# Outcome Review + +## Target Outcome + +## Actual Outcome + +## Delta + +## Root Causes + +## Follow-up Actions diff --git a/.agents/skills/discovery-skill/SKILL.md b/.agents/skills/discovery-skill/SKILL.md new file mode 100644 index 0000000..6710fe0 --- /dev/null +++ b/.agents/skills/discovery-skill/SKILL.md @@ -0,0 +1,44 @@ +--- +name: discovery-skill +description: Define and approve a measurable outcome and Spec. Use when a new delivery request has unclear scope, missing outcome, or missing owner. +--- + +# discovery-skill + +## Trigger context +- New delivery request with undefined scope +- Existing scope lacks clear outcome or owner + +## Required inputs +- project_slug +- project_name +- owner +- outcome_hypothesis +- constraints + +## Output schema +- `.project/projects/<slug>/spec.md` +- clarified outcome statement +- open questions list + +## Quality checks +- measurable success criteria present +- explicit non-goals present +- dependency assumptions documented + +## Failure behavior +- stop if objective is ambiguous +- return a clarification question set + +## Allowed side effects +- create project scaffold through init script +- update `spec.md` + +## Script hooks +- `bash .agents/scripts/pm/init.sh <slug> "<Project Name>" <owner> <lead>` +- `bash .agents/scripts/pm/validate.sh` + +## Execution assets +- `references/runbook.md` +- `templates/clarification-questions.md` +- `templates/discovery-summary.md` diff --git a/.agents/skills/discovery-skill/references/runbook.md b/.agents/skills/discovery-skill/references/runbook.md new file mode 100644 index 0000000..8bbb8a5 --- /dev/null +++ b/.agents/skills/discovery-skill/references/runbook.md @@ -0,0 +1,14 @@ +# Discovery Runbook + +1. Confirm project slug, owner, and measurable outcome. +2. If project scaffold is missing, run: + - `bash .agents/scripts/pm/init.sh <slug> "<Project Name>" <owner> <lead>` +3. Fill `spec.md` using `.project/templates/spec.md`. +4. Ensure non-goals and dependencies are explicit. +5. Validate: + - `bash .agents/scripts/pm/validate.sh` + +Exit gate: +- Spec outcome is measurable +- Non-goals are explicit +- Assumptions are documented diff --git a/.agents/skills/discovery-skill/templates/clarification-questions.md b/.agents/skills/discovery-skill/templates/clarification-questions.md new file mode 100644 index 0000000..0a7a39e --- /dev/null +++ b/.agents/skills/discovery-skill/templates/clarification-questions.md @@ -0,0 +1,18 @@ +# Discovery Clarification Questions + +## Problem +- What exact pain are we solving? +- Who is blocked today and how often? + +## Outcome +- What measurable result defines success? +- What is the target date? + +## Scope +- What is explicitly out of scope? +- What is the smallest viable first delivery? + +## Constraints +- Technical constraints? +- Compliance/security constraints? +- Team/resource constraints? diff --git a/.agents/skills/discovery-skill/templates/discovery-summary.md b/.agents/skills/discovery-skill/templates/discovery-summary.md new file mode 100644 index 0000000..6842a8e --- /dev/null +++ b/.agents/skills/discovery-skill/templates/discovery-summary.md @@ -0,0 +1,14 @@ +# Discovery Summary + +## Outcome Hypothesis + +## Owner + +## Scope (In / Out) + +## Assumptions + +## Open Questions + +## Next Action +- Move to planning once spec is approved. diff --git a/.agents/skills/execution-skill/SKILL.md b/.agents/skills/execution-skill/SKILL.md new file mode 100644 index 0000000..f4f1b4f --- /dev/null +++ b/.agents/skills/execution-skill/SKILL.md @@ -0,0 +1,42 @@ +--- +name: execution-skill +description: Execute mapped tasks with stream discipline, dependency safety checks, and evidence updates. Use when tasks are ready for implementation. +--- + +# execution-skill + +## Trigger context +- tasks are ready and dependency-safe + +## Required inputs +- task_ids +- stream_boundaries +- dependency_state + +## Output schema +- updated task status +- progress updates +- delivery artifacts (commits/PRs/notes) + +## Quality checks +- blockers explicit with owner/check-back time +- progress updates current +- stream boundaries respected + +## Failure behavior +- stop work on hard blockers +- escalate file ownership conflict + +## Allowed side effects +- update task frontmatter/status +- append updates under `.project/projects/<slug>/updates/` + +## Script hooks +- `bash .agents/scripts/pm/in-progress.sh` +- `bash .agents/scripts/pm/standup.sh` +- `bash .agents/scripts/pm/next.sh` + +## Execution assets +- `references/runbook.md` +- `templates/blocker-update.md` +- `templates/stream-update.md` diff --git a/.agents/skills/execution-skill/references/runbook.md b/.agents/skills/execution-skill/references/runbook.md new file mode 100644 index 0000000..ea9371a --- /dev/null +++ b/.agents/skills/execution-skill/references/runbook.md @@ -0,0 +1,16 @@ +# Execution Runbook + +1. Pick dependency-safe task: + - `bash .agents/scripts/pm/next.sh` +2. Set task status to `in-progress`. +3. Execute implementation in owned boundaries. +4. Record updates in `.project/projects/<slug>/updates/...`. +5. Surface blockers immediately: + - `bash .agents/scripts/pm/blocked.sh` +6. Review active work: + - `bash .agents/scripts/pm/in-progress.sh` + +Exit gate: +- Work complete per acceptance criteria +- Evidence log updated +- Task ready for quality/review diff --git a/.agents/skills/execution-skill/templates/blocker-update.md b/.agents/skills/execution-skill/templates/blocker-update.md new file mode 100644 index 0000000..9471a8f --- /dev/null +++ b/.agents/skills/execution-skill/templates/blocker-update.md @@ -0,0 +1,13 @@ +# Blocker Update + +## Task + +## Blocker + +## Blocker Owner + +## Check-back Time + +## Mitigation Attempted + +## Next Safe Action diff --git a/.agents/skills/execution-skill/templates/stream-update.md b/.agents/skills/execution-skill/templates/stream-update.md new file mode 100644 index 0000000..4ce965c --- /dev/null +++ b/.agents/skills/execution-skill/templates/stream-update.md @@ -0,0 +1,9 @@ +# Stream Update + +## Completed + +## In Progress + +## Blockers + +## Next Actions diff --git a/.agents/skills/learning-skill/SKILL.md b/.agents/skills/learning-skill/SKILL.md new file mode 100644 index 0000000..9a15eee --- /dev/null +++ b/.agents/skills/learning-skill/SKILL.md @@ -0,0 +1,41 @@ +--- +name: learning-skill +description: Distill reusable decisions and lessons into project memory and improvement actions. Use at milestones, closeout, or recurring failure patterns. +--- + +# learning-skill + +## Trigger context +- milestone or project closeout +- recurring failure pattern detected + +## Required inputs +- project_slug +- retrospective_notes +- execution_logs + +## Output schema +- updated `decisions.md` +- reusable lessons summary +- follow-up improvement items + +## Quality checks +- every lesson links to observed evidence +- lessons are actionable and specific +- next-cycle improvements are prioritized + +## Failure behavior +- if evidence is weak, return evidence gaps first + +## Allowed side effects +- update `.project/projects/<slug>/decisions.md` +- append to project context/progress docs + +## Script hooks +- `bash .agents/scripts/query-log.sh --last 200` +- `bash .agents/scripts/pm/status.sh` + +## Execution assets +- `references/runbook.md` +- `templates/retrospective.md` +- `templates/improvement-backlog.md` diff --git a/.agents/skills/learning-skill/references/runbook.md b/.agents/skills/learning-skill/references/runbook.md new file mode 100644 index 0000000..28d096a --- /dev/null +++ b/.agents/skills/learning-skill/references/runbook.md @@ -0,0 +1,13 @@ +# Learning Runbook + +1. Gather evidence from updates, logs, and outcomes. +2. Extract decisions and lessons with explicit evidence links. +3. Update `.project/projects/<slug>/decisions.md`. +4. Add prioritized improvement actions for next cycle. +5. Snapshot current project state: + - `bash .agents/scripts/pm/status.sh` + +Exit gate: +- Lessons are actionable +- Reusable patterns are explicit +- Improvement backlog is prioritized diff --git a/.agents/skills/learning-skill/templates/improvement-backlog.md b/.agents/skills/learning-skill/templates/improvement-backlog.md new file mode 100644 index 0000000..61dbe6b --- /dev/null +++ b/.agents/skills/learning-skill/templates/improvement-backlog.md @@ -0,0 +1,10 @@ +# Improvement Backlog + +## P1 +- + +## P2 +- + +## P3 +- diff --git a/.agents/skills/learning-skill/templates/retrospective.md b/.agents/skills/learning-skill/templates/retrospective.md new file mode 100644 index 0000000..07d7641 --- /dev/null +++ b/.agents/skills/learning-skill/templates/retrospective.md @@ -0,0 +1,11 @@ +# Retrospective + +## What Worked + +## What Did Not Work + +## Key Decisions and Why + +## Reusable Patterns + +## Improvements for Next Cycle diff --git a/.agents/skills/manage-context/SKILL.md b/.agents/skills/manage-context/SKILL.md new file mode 100644 index 0000000..466821e --- /dev/null +++ b/.agents/skills/manage-context/SKILL.md @@ -0,0 +1,55 @@ +--- +name: manage-context +description: Repair and maintain `.project/context/` so it reflects current project reality. Use when context files are stale, contradictory, still template-like, after major scope or architecture changes, before handoff, or when execution friction suggests context debt. +--- + +# manage-context + +## Trigger context +- `.project/context/` still contains starter or placeholder language +- implementation reality has drifted from spec, plan, or workstreams +- repeated confusion appears around scope, terminology, ownership, architecture, or testing +- a handoff, restart, or milestone review needs trustworthy context +- a major scope, workflow, or architecture change landed and context was not refreshed + +## Required inputs +- current `.project/context/` files +- related project docs (`spec.md`, `plan.md`, `workstreams/*.md`, `decisions.md`, progress notes) +- recent execution evidence (task state, code changes, review feedback, logs) + +## Output schema +- updated `.project/context/*.md` files where needed +- context debt summary +- explicit contradictions or evidence gaps list +- recommended follow-up actions when context cannot be repaired safely + +## Quality checks +- no obvious template placeholders remain +- context matches current implementation and delivery reality +- terminology is consistent across files +- scope, constraints, and non-goals are explicit +- progress reflects evidence, not aspiration +- unresolved uncertainty is stated plainly instead of being hidden + +## Failure behavior +- do not invent missing facts +- stop short of rewriting uncertain sections as if they were confirmed +- return evidence gaps and contradiction notes when repair is partial +- prefer an explicit partial refresh over fake completeness + +## Allowed side effects +- update files under `.project/context/` +- remove stale placeholder text from context files +- normalize duplicated or conflicting phrasing across context files +- add concise dated notes when they improve handoff clarity + +## Script hooks +- `bash .agents/scripts/pm/validate.sh` +- `bash .agents/scripts/pm/status.sh` +- `bash .agents/scripts/pm/search.sh "<term>"` + +## Execution assets +- `references/runbook.md` +- `references/context-audit-checklist.md` +- `templates/context-debt-report.md` +- `templates/context-refresh-summary.md` diff --git a/.agents/skills/manage-context/references/context-audit-checklist.md b/.agents/skills/manage-context/references/context-audit-checklist.md new file mode 100644 index 0000000..dc2e5ec --- /dev/null +++ b/.agents/skills/manage-context/references/context-audit-checklist.md @@ -0,0 +1,26 @@ +# Context Audit Checklist + +Use this checklist before declaring the context pack healthy. + +## Reality check +- Does `project-overview.md` describe the project as it exists now? +- Does `project-brief.md` still match the active outcome and constraints? +- Does `tech-context.md` match the real implementation shape? +- Does `product-context.md` match the real user or delivery problem? +- Does `progress.md` describe actual current status rather than intended status? + +## Drift check +- Are there claims that conflict with `spec.md`, `plan.md`, or workstreams? +- Are there terms used inconsistently across context files? +- Are owners, boundaries, or dependencies implied in one file but absent in others? +- Are testing expectations current, especially in `gui-testing.md`? + +## Template debt check +- Are placeholder markers or generic starter phrases still present? +- Are sections filled with boilerplate rather than repo-specific facts? +- Does the pack still read like an install scaffold instead of a lived project? + +## Handoff check +- Could a new agent or teammate resume work from this context pack without guessing? +- Are the main constraints, risks, and non-goals easy to find? +- Are open questions explicit? diff --git a/.agents/skills/manage-context/references/runbook.md b/.agents/skills/manage-context/references/runbook.md new file mode 100644 index 0000000..c0dc85d --- /dev/null +++ b/.agents/skills/manage-context/references/runbook.md @@ -0,0 +1,26 @@ +# Context Runbook + +1. Read `.project/context/README.md` to confirm the expected context pack shape. +2. Review all current `.project/context/*.md` files. +3. Cross-check the context pack against the active source of truth: + - `.project/projects/<slug>/spec.md` + - `.project/projects/<slug>/plan.md` + - `.project/projects/<slug>/workstreams/*.md` + - `.project/projects/<slug>/decisions.md` + - recent task state, code changes, and review feedback +4. Mark context debt before editing: + - stale claims + - template placeholders + - contradictory terminology + - missing constraints or ownership + - progress statements without evidence +5. Repair only what the evidence supports. +6. Record unresolved contradictions or evidence gaps explicitly. +7. Validate: + - `bash .agents/scripts/pm/validate.sh` + - `bash .agents/scripts/pm/status.sh` + +Exit gate: +- context pack is trustworthy enough for handoff or resumed execution +- open uncertainty is visible +- no placeholder text remains in edited sections diff --git a/.agents/skills/manage-context/templates/context-debt-report.md b/.agents/skills/manage-context/templates/context-debt-report.md new file mode 100644 index 0000000..b5a63e5 --- /dev/null +++ b/.agents/skills/manage-context/templates/context-debt-report.md @@ -0,0 +1,22 @@ +# Context Debt Report + +## Summary + +## Stale or Contradictory Sections +- File: +- Issue: +- Evidence: +- Action: + +## Placeholder or Template Debt +- File: +- Section: +- Replacement needed: + +## Missing Context +- Gap: +- Why it matters: +- Best source of truth: + +## Recommended Follow-up +- diff --git a/.agents/skills/manage-context/templates/context-refresh-summary.md b/.agents/skills/manage-context/templates/context-refresh-summary.md new file mode 100644 index 0000000..6655d6a --- /dev/null +++ b/.agents/skills/manage-context/templates/context-refresh-summary.md @@ -0,0 +1,13 @@ +# Context Refresh Summary + +## Refreshed Files +- + +## What Changed +- + +## Remaining Uncertainty +- + +## Next Action +- Resume execution or handoff using the refreshed context pack. diff --git a/.agents/skills/onboarding/SKILL.md b/.agents/skills/onboarding/SKILL.md new file mode 100644 index 0000000..0aebafa --- /dev/null +++ b/.agents/skills/onboarding/SKILL.md @@ -0,0 +1,49 @@ +--- +name: onboarding +description: Analyze a repository `AGENTS.md` before broader work begins and improve it only when the user gives explicit approval. Use when the user wants a first-pass review of repo instructions, wants to tighten `AGENTS.md` quality, or needs a compact operating-rules and source-of-truth map for future agent turns. +--- + +# onboarding + +## Trigger context +- the user wants an explicit first-turn onboarding pass for a repository +- `AGENTS.md` exists but may be too thin, stale, or missing key guidance +- the user asks to review, critique, or improve repo-level agent instructions +- Delano has just been introduced and `AGENTS.md` should be checked before deeper work starts + +## Required inputs +- repo-root `AGENTS.md` +- directly relevant source-of-truth docs referenced by `AGENTS.md` +- explicit user approval before analyzing `AGENTS.md` +- separate explicit user approval before editing `AGENTS.md` + +## Output schema +- concise gap analysis against `references/agents-md-best-practices.md` +- keep/add/remove recommendations tied to the repo's actual workflow +- if edit approval is given, an updated `AGENTS.md` that stays compact and retrieval-oriented +- explicit note when analysis or edits were skipped because approval was not given + +## Quality checks +- do not analyze `AGENTS.md` until the user explicitly approves the review +- do not edit `AGENTS.md` until the user explicitly approves changes +- preserve repo-specific truth instead of pasting generic boilerplate +- keep `AGENTS.md` compact and point to deeper docs instead of duplicating them +- make approval boundaries, order of operations, and verification expectations explicit + +## Failure behavior +- if approval is missing, stop and ask plainly +- if `AGENTS.md` is absent, report that and propose only a minimal skeleton +- if source-of-truth docs disagree, surface the conflict instead of flattening it +- if the repo intentionally keeps `AGENTS.md` thin, prefer the minimum coherent improvement + +## Allowed side effects +- read `AGENTS.md` and directly relevant source-of-truth docs +- update `AGENTS.md` only after explicit edit approval +- add concise retrieval hints that point to canonical docs + +## Script hooks +- `delano onboarding` +- `bash .agents/scripts/pm/validate.sh` + +## Execution assets +- `references/agents-md-best-practices.md` diff --git a/.agents/skills/onboarding/references/agents-md-best-practices.md b/.agents/skills/onboarding/references/agents-md-best-practices.md new file mode 100644 index 0000000..71f6cd9 --- /dev/null +++ b/.agents/skills/onboarding/references/agents-md-best-practices.md @@ -0,0 +1,76 @@ +# How to write an AGENTS.md that works + +AGENTS.md carries the context the agent needs every turn. Skills and `docs/` hold optional, task-specific workflows. Do not confuse the two. + +## What belongs in AGENTS.md + +1. Operating rules: startup sequence, approval boundaries, destructive-action handling, definition of done, default workspace. +2. Source-of-truth map: compact index pointing to where real knowledge lives. Include version-sensitive areas and "if working on X, read Y first" hints. +3. Order-of-operations: phrase as a sequence, not a blanket rule. Not "always read docs first" but: explore structure -> retrieve relevant docs -> implement -> verify. +4. Stable, high-impact constraints: repo location, branch policy, style rules, runtime quirks, business-logic conventions. High-frequency, high-impact, easy to miss. + +## What does not belong + +Long prose, full runbooks, rarely-used procedures, or anything duplicated from `docs/`. Point to it instead. + +## Example skeleton (Delano-style) + +```markdown +## Mission +One line: what this project is, what good work looks like. + +## First-Turn Workflow +1. Inspect repo structure and current git state before assuming shape or ownership. +2. Read the relevant local source of truth for the area being changed. +3. Prefer current repo reality over stale plans or model memory. +4. Make the smallest coherent change that satisfies the task. +5. Verify with the narrowest meaningful check, then report done / partial / blocked explicitly. + +## Source of Truth +- `HANDBOOK.md`: delivery model, project contracts, evidence, continuity rules. +- `ARCHITECTURE.md`: product architecture and runtime/orchestration model. +- `.project/context/`: current project memory - start with `README.md`, then only what is relevant. +- `.project/projects/<project>/`: active delivery contracts (`spec.md`, `plan.md`, `decisions.md`, `workstreams/`, `tasks/`, `updates/`). +- `.agents/`: shared delivery/runtime assets, skills, rules, hooks, PM scripts. +- `README.md`: product status, setup, commands, operational gaps. +- `src/`, `skills/`, `starter/`, `fixtures/`, `tests/`: implemented surface. +- `possible-specs.md`: archived only - not active guidance. + +## Retrieval Index +- Delivery workflow -> `HANDBOOK.md` + matching `.agents/skills/<step>-skill/SKILL.md` +- Active scope / acceptance -> `spec.md` + relevant task files +- Architecture / runtime -> `ARCHITECTURE.md`, `plan.md`, `.project/context/system-patterns.md` +- Repo layout -> `.project/context/project-structure.md` +- Stack / commands -> `.project/context/tech-context.md`, `README.md`, `package.json` +- Style / docs conventions -> `.project/context/project-style-guide.md` +- GUI/browser checks -> `.project/context/gui-testing.md` +- Status / evidence -> `.project/context/progress.md`, `updates/` +- CRM/provider work -> `src/connectors/`, `fixtures/providers/`, provider tests +- Starter/runtime assets -> `starter/`, `.runtime/` expectations, starter validation tests + +## Delano Order of Operations +Use the full flow for features, contract changes, or material improvements: +1. Discovery - define measurable outcome in `spec.md` (`discovery-skill`). +2. Prototype Probe - time-boxed, only if uncertainty is high; findings back to spec. +3. Planning - architecture, milestones, rollout, rollback in `plan.md` (`planning-skill`). +4. Breakdown - atomic tasks, binary acceptance, acyclic dependencies (`breakdown-skill`). +5. Synchronization - reconcile with Linear/GitHub when tracker state is involved (`sync-skill`). +6. Execution - dependency-safe tasks inside workstream boundaries, evidence in `updates/` (`execution-skill`). +7. Quality Ops - risk-based checks, verify acceptance before closure (`quality-skill`). +8. Closeout - compare to outcome, update project memory, close the loop (`closeout-skill`). + +For small local fixes: follow the first-turn workflow; update delivery/context files only when scope, architecture, status, or evidence changes. + +## Safety +- No destructive actions without approval. +- Prefer recoverable edits. +- Confirm before outbound/public actions. + +## Verification +- Run lint/test/build when relevant; if skipped, say why. +- Mark done / partial / blocked explicitly. +``` + +## Core principle + +AGENTS.md should contain the minimum persistent context needed to make correct decisions reliably, and a compact map for retrieving everything else. diff --git a/.agents/skills/planning-skill/SKILL.md b/.agents/skills/planning-skill/SKILL.md new file mode 100644 index 0000000..ddc699b --- /dev/null +++ b/.agents/skills/planning-skill/SKILL.md @@ -0,0 +1,40 @@ +--- +name: planning-skill +description: Translate an approved Spec into an executable Delivery Plan and workstreams. Use after spec approval before task decomposition. +--- + +# planning-skill + +## Trigger context +- `spec.md` approved and active + +## Required inputs +- spec_path +- architecture_constraints +- dependency_inputs + +## Output schema +- `.project/projects/<slug>/plan.md` +- `.project/projects/<slug>/workstreams/*.md` + +## Quality checks +- architecture decisions justified +- rollout and rollback path documented +- workstream boundaries explicit + +## Failure behavior +- stop on unresolved architectural conflicts +- return tradeoff matrix and decision prompts + +## Allowed side effects +- create/update `plan.md` +- create/update `workstreams/*.md` + +## Script hooks +- `bash .agents/scripts/pm/validate.sh` +- `bash .agents/scripts/pm/status.sh` + +## Execution assets +- `references/runbook.md` +- `templates/architecture-decision.md` +- `templates/workstream-definition.md` diff --git a/.agents/skills/planning-skill/references/runbook.md b/.agents/skills/planning-skill/references/runbook.md new file mode 100644 index 0000000..a550b8d --- /dev/null +++ b/.agents/skills/planning-skill/references/runbook.md @@ -0,0 +1,15 @@ +# Planning Runbook + +1. Read `spec.md` and extract constraints. +2. Draft `plan.md` from `.project/templates/plan.md`. +3. Define initial workstreams from `.project/templates/workstream.md`. +4. Add milestone, rollout, rollback, and test strategies. +5. Validate: + - `bash .agents/scripts/pm/validate.sh` +6. Snapshot: + - `bash .agents/scripts/pm/status.sh` + +Exit gate: +- Architecture decisions justified +- Workstream ownership boundaries clear +- Rollout/rollback explicit diff --git a/.agents/skills/planning-skill/templates/architecture-decision.md b/.agents/skills/planning-skill/templates/architecture-decision.md new file mode 100644 index 0000000..d9ea768 --- /dev/null +++ b/.agents/skills/planning-skill/templates/architecture-decision.md @@ -0,0 +1,15 @@ +# Architecture Decision + +## Context + +## Decision + +## Alternatives Considered +- Option A: +- Option B: + +## Rationale + +## Consequences + +## Follow-up Actions diff --git a/.agents/skills/planning-skill/templates/workstream-definition.md b/.agents/skills/planning-skill/templates/workstream-definition.md new file mode 100644 index 0000000..b0120cc --- /dev/null +++ b/.agents/skills/planning-skill/templates/workstream-definition.md @@ -0,0 +1,13 @@ +# Workstream Definition + +## Name + +## Objective + +## Owned Files/Areas + +## Dependencies + +## Conflict Risk Zones + +## Handoff Criteria diff --git a/.agents/skills/prototype-skill/SKILL.md b/.agents/skills/prototype-skill/SKILL.md new file mode 100644 index 0000000..2c9cd61 --- /dev/null +++ b/.agents/skills/prototype-skill/SKILL.md @@ -0,0 +1,51 @@ +--- +name: prototype-skill +description: Run a time-boxed Prototype Probe to retire material uncertainty before spec approval. Use when `spec.md` is still draft, `probe_required` is true, or a narrow experiment is needed to bound technical or delivery risk before planning. +--- + +# prototype-skill + +## Trigger context +- `spec.md` is still draft and contains material uncertainty +- `probe_required: true` +- approval would otherwise be speculative +- a narrow experiment can retire a specific technical, UX, integration, or delivery risk faster than discussion alone + +## Required inputs +- `spec.md` with uncertainty and probe fields populated +- target uncertainty to retire or bound +- time-box and experiment constraints +- success or failure evidence expected from the probe + +## Output schema +- updated draft `spec.md` +- probe findings summary +- explicit approval recommendation (`approve`, `revise`, or `run another narrow probe`) +- touched surfaces and footguns list + +## Quality checks +- probe stays time-boxed, normally `<= 1 day` +- experiment is narrow and directly tied to the uncertainty being tested +- no production merge happens directly from probe output +- findings are folded back into `spec.md` before continuation +- touched surfaces, footguns, and remaining uncertainty are explicit + +## Failure behavior +- stop if the probe is too broad, open-ended, or not tied to a material uncertainty +- return a narrower probe design when the current one is not safe or useful +- do not present exploratory output as production-ready implementation + +## Allowed side effects +- update draft `spec.md` +- create or update temporary probe notes inside the project folder when needed +- record approval recommendation and follow-up actions + +## Script hooks +- `bash .agents/scripts/pm/validate.sh` +- `bash .agents/scripts/pm/status.sh` + +## Execution assets +- `references/runbook.md` +- `references/probe-design-checklist.md` +- `templates/probe-findings.md` +- `templates/probe-approval-recommendation.md` diff --git a/.agents/skills/prototype-skill/references/probe-design-checklist.md b/.agents/skills/prototype-skill/references/probe-design-checklist.md new file mode 100644 index 0000000..488e5f3 --- /dev/null +++ b/.agents/skills/prototype-skill/references/probe-design-checklist.md @@ -0,0 +1,26 @@ +# Probe Design Checklist + +Use this before running a Prototype Probe. + +## Probe quality +- Is the uncertainty material enough to justify a probe? +- Is the probe tied to one concrete question? +- Is the probe smaller than full implementation? +- Can the result change the spec approval decision? + +## Scope control +- What is explicitly in scope? +- What is explicitly out of scope? +- Which surfaces may be touched? +- What would make this probe too broad? + +## Evidence +- What outcome would justify approval? +- What outcome would require revision? +- What outcome would suggest another narrow probe? +- What footguns or side effects must be recorded even if the probe succeeds? + +## Safety +- Is there any risk of probe code being treated like production-ready output? +- Is there a clean way to prevent direct merge from probe artifacts? +- Are follow-up tasks likely to be needed after the probe? diff --git a/.agents/skills/prototype-skill/references/runbook.md b/.agents/skills/prototype-skill/references/runbook.md new file mode 100644 index 0000000..adc7e63 --- /dev/null +++ b/.agents/skills/prototype-skill/references/runbook.md @@ -0,0 +1,27 @@ +# Prototype Probe Runbook + +1. Read the draft `spec.md` and identify the single material uncertainty to retire or bound. +2. Confirm `probe_required: true` and keep the probe narrow. +3. Define the smallest useful experiment: + - what is being tested + - what evidence is needed + - what touched surfaces are in scope + - what is explicitly out of scope +4. Time-box the probe, normally to `<= 1 day`. +5. Run the experiment using the safest path, CLI-first when feasible. +6. Do not merge probe output directly into production delivery flow. +7. Fold findings back into `spec.md`: + - what changed after probe + - touched surfaces + - footguns + - remaining uncertainty + - approval recommendation +8. Validate if contracts changed: + - `bash .agents/scripts/pm/validate.sh` +9. Snapshot status when useful: + - `bash .agents/scripts/pm/status.sh` + +Exit gate: +- probe findings recorded +- approval recommendation is clear +- next step is explicit: approve, revise, or run another narrow probe diff --git a/.agents/skills/prototype-skill/templates/probe-approval-recommendation.md b/.agents/skills/prototype-skill/templates/probe-approval-recommendation.md new file mode 100644 index 0000000..12bbb4f --- /dev/null +++ b/.agents/skills/prototype-skill/templates/probe-approval-recommendation.md @@ -0,0 +1,13 @@ +# Probe Approval Recommendation + +## Recommendation +- Approve +- Revise +- Run another narrow probe + +## Why + +## Remaining Uncertainty + +## Immediate Next Action +- diff --git a/.agents/skills/prototype-skill/templates/probe-findings.md b/.agents/skills/prototype-skill/templates/probe-findings.md new file mode 100644 index 0000000..c92d9bf --- /dev/null +++ b/.agents/skills/prototype-skill/templates/probe-findings.md @@ -0,0 +1,16 @@ +# Probe Findings + +## Uncertainty Tested + +## Experiment Shape + +## Evidence Observed + +## Touched Surfaces +- + +## Footguns +- + +## What Changed In The Spec +- diff --git a/.agents/skills/quality-skill/SKILL.md b/.agents/skills/quality-skill/SKILL.md new file mode 100644 index 0000000..5dfb098 --- /dev/null +++ b/.agents/skills/quality-skill/SKILL.md @@ -0,0 +1,40 @@ +--- +name: quality-skill +description: Verify release readiness and enforce quality gates with risk-based checks and evidence capture. Use before closure or merge. +--- + +# quality-skill + +## Trigger context +- target tasks are implemented and ready for verification + +## Required inputs +- changed_scope +- risk_level +- test_requirements + +## Output schema +- quality_evidence bundle +- pass/fail gate decision + +## Quality checks +- required tests executed by risk level +- acceptance criteria complete +- unresolved critical defects = 0 + +## Failure behavior +- stop merge readiness on failed critical checks +- emit remediation checklist + +## Allowed side effects +- append evidence logs in task files +- write test logs under `.agents/logs/tests/` + +## Script hooks +- `bash .agents/scripts/test-and-log.sh <command>` +- `bash .agents/scripts/pm/validate.sh` + +## Execution assets +- `references/runbook.md` +- `templates/quality-evidence.md` +- `templates/gate-decision.md` diff --git a/.agents/skills/quality-skill/references/runbook.md b/.agents/skills/quality-skill/references/runbook.md new file mode 100644 index 0000000..eb6499f --- /dev/null +++ b/.agents/skills/quality-skill/references/runbook.md @@ -0,0 +1,14 @@ +# Quality Runbook + +1. Determine risk level (low/medium/high). +2. Execute required tests and capture logs: + - `bash .agents/scripts/test-and-log.sh <test command>` +3. Verify acceptance criteria and evidence completeness. +4. Re-run validation: + - `bash .agents/scripts/pm/validate.sh` +5. Produce gate decision summary. + +Exit gate: +- Required checks pass for risk level +- Critical unresolved defects = 0 +- Evidence links are present diff --git a/.agents/skills/quality-skill/templates/gate-decision.md b/.agents/skills/quality-skill/templates/gate-decision.md new file mode 100644 index 0000000..dd25ab5 --- /dev/null +++ b/.agents/skills/quality-skill/templates/gate-decision.md @@ -0,0 +1,10 @@ +# Quality Gate Decision + +## Decision +- pass / fail + +## Reasoning + +## Blocking Items (if fail) + +## Required Follow-up diff --git a/.agents/skills/quality-skill/templates/quality-evidence.md b/.agents/skills/quality-skill/templates/quality-evidence.md new file mode 100644 index 0000000..3d2394e --- /dev/null +++ b/.agents/skills/quality-skill/templates/quality-evidence.md @@ -0,0 +1,16 @@ +# Quality Evidence + +## Scope + +## Risk Level + +## Tests Run +- Unit: +- Integration: +- GUI/E2E: + +## Results + +## Defects Found + +## Evidence Links diff --git a/.agents/skills/research-skill/SKILL.md b/.agents/skills/research-skill/SKILL.md new file mode 100644 index 0000000..7505481 --- /dev/null +++ b/.agents/skills/research-skill/SKILL.md @@ -0,0 +1,66 @@ +--- +name: research-skill +description: Open and run repo-native research intake before mutating canonical delivery artifacts. Use when a Delano request has unclear intent, unresolved options, external evidence needs, or material uncertainty that should be investigated before changing spec, plan, workstreams, or tasks. +--- + +# research-skill + +## Trigger context +- delivery intent is unclear enough that direct changes to `spec.md`, `plan.md`, workstreams, or tasks would be speculative +- options, constraints, risks, or external evidence need investigation before planning or execution +- imported or user-provided material needs synthesis into Delano's canonical project artifacts +- a previous `planning_with_files` style briefing would have been useful, but the work must stay inside the Delano repo + +## Non-triggers +- obvious implementation tasks with accepted scope +- simple bug fixes or one-file edits +- work that already has an approved spec, plan, and ready tasks +- personal Obsidian briefing or vault-based planning + +## Required inputs +- project_slug +- research_slug +- research_title +- primary_question +- owner +- known_constraints + +## Output schema +- `.project/projects/<slug>/research/<research-slug>/task_plan.md` +- `.project/projects/<slug>/research/<research-slug>/findings.md` +- `.project/projects/<slug>/research/<research-slug>/progress.md` +- folded-forward updates to `spec.md`, `plan.md`, `decisions.md`, workstreams, tasks, or update notes when conclusions are durable +- explicit no-action closeout when research does not change canonical artifacts + +## Quality checks +- research question is specific enough to answer +- findings cite inspected files, commands, sources, or artifacts +- progress log records actions, validation, blockers, and closeout +- durable conclusions are folded into canonical Delano project artifacts +- research files do not contain secrets, credentials, private machine paths, or Obsidian vault paths +- research output is not treated as executable task truth until folded forward + +## Failure behavior +- stop if project slug does not exist +- return a narrower research question when the current question is too broad +- document evidence gaps before recommending artifact changes +- leave canonical project files unchanged when findings are weak or unresolved + +## Allowed side effects +- create a research intake folder under `.project/projects/<slug>/research/<research-slug>/` +- update `task_plan.md`, `findings.md`, and `progress.md` during investigation +- update canonical Delano artifacts only after evidence supports the change +- run Delano validation after creating or folding forward research + +## Script hooks +- `bash .agents/scripts/pm/research.sh <project-slug> <research-slug> --title "<Research Title>" --question "<Primary Question>" --owner <owner> --json` +- `bash .agents/scripts/pm/validate.sh` +- `bash .agents/scripts/pm/status.sh` + +## Lineage +This skill adapts Bart's `planning_with_files` pattern to Delano. Keep the useful three-file working state and closeout discipline, but do not use Obsidian, `BartsVault`, or external briefing folders. Delano research belongs inside the project repository. + +## Execution assets +- `references/runbook.md` +- `templates/research-summary.md` +- `templates/fold-forward-checklist.md` diff --git a/.agents/skills/research-skill/references/runbook.md b/.agents/skills/research-skill/references/runbook.md new file mode 100644 index 0000000..a06f643 --- /dev/null +++ b/.agents/skills/research-skill/references/runbook.md @@ -0,0 +1,59 @@ +# research-skill runbook + +Use research intake as Delano's repo-native version of file-based planning for unclear work. It gives agents durable working state without moving the source of truth out of the repository. + +## 1. Decide whether research is needed + +Open research when the next canonical artifact change would otherwise be a guess. Good triggers include unclear imported requirements, competing implementation options, missing evidence, uncertain user intent, and questions that need investigation before delivery planning. + +Skip research when the work is already decided and executable. Use `execution-skill`, `planning-skill`, or `quality-skill` directly instead. + +## 2. Open the intake + +Run: + +```bash +bash .agents/scripts/pm/research.sh <project-slug> <research-slug> \ + --title "<Research Title>" \ + --question "<Primary Question>" \ + --owner <owner> \ + --json +``` + +The command creates: +- `task_plan.md` +- `findings.md` +- `progress.md` + +under: +- `.project/projects/<project-slug>/research/<research-slug>/` + +Do not create Obsidian briefings for Delano research. + +## 3. Work inside the intake + +Use `task_plan.md` for phase state, `findings.md` for evidence and conclusions, and `progress.md` for chronological actions, tests, blockers, and handoff notes. + +Keep entries concise and evidence-led. Cite local files, commands, issue references, PRs, docs, or external sources that were actually inspected. + +## 4. Fold forward + +Research is not done just because the three files exist. Durable conclusions must be folded into canonical Delano artifacts: +- `spec.md` +- `plan.md` +- `decisions.md` +- `workstreams/*.md` +- `tasks/*.md` +- `updates/*.md` + +If the answer is no-action, record why in `progress.md` and keep canonical files unchanged. + +## 5. Validate and report + +Run validation after creating intake files and again after folding conclusions forward: + +```bash +bash .agents/scripts/pm/validate.sh +``` + +Report the research path, conclusion, folded-forward files, validation result, and remaining open items. diff --git a/.agents/skills/research-skill/templates/fold-forward-checklist.md b/.agents/skills/research-skill/templates/fold-forward-checklist.md new file mode 100644 index 0000000..c691279 --- /dev/null +++ b/.agents/skills/research-skill/templates/fold-forward-checklist.md @@ -0,0 +1,9 @@ +# Fold-forward Checklist + +- [ ] Research question answered or explicitly narrowed +- [ ] Evidence and gaps recorded in `findings.md` +- [ ] Actions, validation, and blockers recorded in `progress.md` +- [ ] Durable conclusions copied into canonical Delano artifacts +- [ ] No secrets, credentials, private machine paths, or Obsidian vault paths included +- [ ] `bash .agents/scripts/pm/validate.sh` run after changes +- [ ] No-action closeout recorded if canonical artifacts stayed unchanged diff --git a/.agents/skills/research-skill/templates/research-summary.md b/.agents/skills/research-skill/templates/research-summary.md new file mode 100644 index 0000000..7505b93 --- /dev/null +++ b/.agents/skills/research-skill/templates/research-summary.md @@ -0,0 +1,21 @@ +# Research Summary + +## Question + +<primary research question> + +## Evidence Inspected + +- <file, command, source, issue, PR, or artifact> + +## Findings + +- <finding tied to evidence> + +## Recommendation + +<recommended Delano artifact change or no-action decision> + +## Confidence + +<high|medium|low> because <reason> diff --git a/.agents/skills/sync-skill/SKILL.md b/.agents/skills/sync-skill/SKILL.md new file mode 100644 index 0000000..d3a79d6 --- /dev/null +++ b/.agents/skills/sync-skill/SKILL.md @@ -0,0 +1,41 @@ +--- +name: sync-skill +description: Reconcile local contracts with Linear and GitHub state, repair mapping and status drift, and update registry files. Use when task state changes or sync drift is suspected. +--- + +# sync-skill + +## Trigger context +- active tasks changed +- status or dependency drift suspected + +## Required inputs +- project_slug +- local_registry +- task_files + +## Output schema +- updated_registry +- drift_report + +## Quality checks +- active tasks mapped +- no duplicate mapping +- dependency parity pass + +## Failure behavior +- dry-run when uncertainty detected +- emit conflict resolution actions + +## Allowed side effects +- update `.project/registry/linear-map.json` +- update local IDs/links in task contracts + +## Script hooks +- `bash .agents/scripts/pm/status.sh` +- `bash .agents/scripts/pm/validate.sh` + +## Execution assets +- `references/runbook.md` +- `templates/drift-report.md` +- `templates/conflict-resolution-actions.md` diff --git a/.agents/skills/sync-skill/references/runbook.md b/.agents/skills/sync-skill/references/runbook.md new file mode 100644 index 0000000..5ac050d --- /dev/null +++ b/.agents/skills/sync-skill/references/runbook.md @@ -0,0 +1,17 @@ +# Sync Runbook + +1. Read local contracts and `.project/registry/linear-map.json`. +2. Compare with Linear/GitHub state. +3. Apply idempotent cycle: + - create missing mappings + - update changed statuses/links + - preserve existing IDs +4. Write drift report and mapping updates. +5. Validate: + - `bash .agents/scripts/pm/status.sh` + - `bash .agents/scripts/pm/validate.sh` + +Exit gate: +- No orphaned active tasks +- No duplicate mappings +- Status/dependency parity confirmed diff --git a/.agents/skills/sync-skill/templates/conflict-resolution-actions.md b/.agents/skills/sync-skill/templates/conflict-resolution-actions.md new file mode 100644 index 0000000..39f014a --- /dev/null +++ b/.agents/skills/sync-skill/templates/conflict-resolution-actions.md @@ -0,0 +1,10 @@ +# Conflict Resolution Actions + +## Conflict + +## Proposed Resolution + +## Needs Human Decision? +- yes / no + +## Safe Next Step diff --git a/.agents/skills/sync-skill/templates/drift-report.md b/.agents/skills/sync-skill/templates/drift-report.md new file mode 100644 index 0000000..49a7d18 --- /dev/null +++ b/.agents/skills/sync-skill/templates/drift-report.md @@ -0,0 +1,14 @@ +# Drift Report + +## Mapping Drift + +## Status Drift + +## Dependency Drift + +## Orphan Drift + +## Risk Level +- low | medium | high + +## Recommended Action diff --git a/.agents/validation-fixtures/strict/invalid/broken-dependencies/dependency.md b/.agents/validation-fixtures/strict/invalid/broken-dependencies/dependency.md new file mode 100644 index 0000000..d9c0b5b --- /dev/null +++ b/.agents/validation-fixtures/strict/invalid/broken-dependencies/dependency.md @@ -0,0 +1,18 @@ +--- +id: T-001 +name: Undone dependency fixture +status: ready +workstream: WS-A +created: 2026-04-29T00:00:00Z +updated: 2026-04-29T00:10:00Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [] +conflicts_with: [] +parallel: false +priority: high +estimate: S +--- + +# Task: Undone dependency fixture diff --git a/.agents/validation-fixtures/strict/invalid/broken-dependencies/task.md b/.agents/validation-fixtures/strict/invalid/broken-dependencies/task.md new file mode 100644 index 0000000..d46a314 --- /dev/null +++ b/.agents/validation-fixtures/strict/invalid/broken-dependencies/task.md @@ -0,0 +1,24 @@ +--- +id: T-002 +name: Broken dependencies fixture +status: done +workstream: WS-A +created: 2026-04-29T00:00:00Z +updated: 2026-04-29T00:10:00Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-001] +conflicts_with: [] +parallel: false +priority: high +estimate: S +--- + +# Task: Broken dependencies fixture + +## Acceptance Criteria +- [x] Current repo state has been inspected before implementation starts. + +## Evidence Log +- 2026-04-29T00:10:00Z: Inspected fixture and validation passed despite intentionally broken dependency. diff --git a/.agents/validation-fixtures/strict/invalid/invalid-transition/task.md b/.agents/validation-fixtures/strict/invalid/invalid-transition/task.md new file mode 100644 index 0000000..7f2d855 --- /dev/null +++ b/.agents/validation-fixtures/strict/invalid/invalid-transition/task.md @@ -0,0 +1,20 @@ +--- +id: T-001 +name: Invalid transition fixture +status: blocked +workstream: WS-A +created: 2026-04-29T00:00:00Z +updated: 2026-04-29T00:10:00Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [] +conflicts_with: [] +parallel: false +priority: high +estimate: S +--- + +# Task: Invalid transition fixture + +Blocked without owner or check-back. diff --git a/.agents/validation-fixtures/strict/invalid/missing-evidence/task.md b/.agents/validation-fixtures/strict/invalid/missing-evidence/task.md new file mode 100644 index 0000000..a250c68 --- /dev/null +++ b/.agents/validation-fixtures/strict/invalid/missing-evidence/task.md @@ -0,0 +1,27 @@ +--- +id: T-001 +name: Missing evidence fixture +status: done +workstream: WS-A +created: 2026-04-29T00:00:00Z +updated: 2026-04-29T00:10:00Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [] +conflicts_with: [] +parallel: false +priority: high +estimate: S +--- + +# Task: Missing evidence fixture + +## Acceptance Criteria +- [x] Current repo state has been inspected before implementation starts. +- [x] The delivered change is represented in Delano runtime assets, project contracts, validation, fixtures, or docs as appropriate. +- [x] The change is validated with the smallest meaningful command or fixture. +- [x] Evidence is recorded in this task or a task update before the task is marked done. + +## Evidence Log +- 2026-04-29T00:00:00Z: Task created; implementation evidence pending. diff --git a/.agents/validation-fixtures/strict/invalid/path-leak/task.md b/.agents/validation-fixtures/strict/invalid/path-leak/task.md new file mode 100644 index 0000000..22a0d52 --- /dev/null +++ b/.agents/validation-fixtures/strict/invalid/path-leak/task.md @@ -0,0 +1,27 @@ +--- +id: T-001 +name: Path leak fixture +status: done +workstream: WS-A +created: 2026-04-29T00:00:00Z +updated: 2026-04-29T00:10:00Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [] +conflicts_with: [] +parallel: false +priority: high +estimate: S +--- + +# Task: Path leak fixture + +Leaked path: PATH_LEAK_TOKEN(home::example::private::delano) +WSL leak form: PATH_LEAK_TOKEN(wsl-mount::example::private::delano) + +## Acceptance Criteria +- [x] Current repo state has been inspected before implementation starts. + +## Evidence Log +- 2026-04-29T00:10:00Z: Inspected fixture and validation passed. diff --git a/.agents/validation-fixtures/strict/invalid/stale-context/context.md b/.agents/validation-fixtures/strict/invalid/stale-context/context.md new file mode 100644 index 0000000..b8eca9c --- /dev/null +++ b/.agents/validation-fixtures/strict/invalid/stale-context/context.md @@ -0,0 +1,9 @@ +--- +created: 2026-04-01T00:00:00Z +updated: 2026-04-01T00:00:00Z +review_by: 2026-04-15T00:00:00Z +--- + +# Stale context fixture + +This fixture is intentionally past its review date. diff --git a/.agents/validation-fixtures/strict/manifest.json b/.agents/validation-fixtures/strict/manifest.json new file mode 100644 index 0000000..2864867 --- /dev/null +++ b/.agents/validation-fixtures/strict/manifest.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "fixtures": [ + { "name": "minimal-project", "kind": "valid", "path": "valid/minimal-project", "expected": "pass" }, + { "name": "missing-evidence", "kind": "invalid", "path": "invalid/missing-evidence", "expected_rule": "missing-evidence" }, + { "name": "broken-dependencies", "kind": "invalid", "path": "invalid/broken-dependencies", "expected_rule": "broken-dependencies" }, + { "name": "stale-context", "kind": "invalid", "path": "invalid/stale-context", "expected_rule": "stale-context" }, + { "name": "path-leak", "kind": "invalid", "path": "invalid/path-leak", "expected_rule": "path-leak" }, + { "name": "invalid-transition", "kind": "invalid", "path": "invalid/invalid-transition", "expected_rule": "invalid-transition" } + ] +} diff --git a/.agents/validation-fixtures/strict/valid/minimal-project/task.md b/.agents/validation-fixtures/strict/valid/minimal-project/task.md new file mode 100644 index 0000000..72400ee --- /dev/null +++ b/.agents/validation-fixtures/strict/valid/minimal-project/task.md @@ -0,0 +1,27 @@ +--- +id: T-001 +name: Valid strict fixture task +status: done +workstream: WS-A +created: 2026-04-29T00:00:00Z +updated: 2026-04-29T00:10:00Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [] +conflicts_with: [] +parallel: false +priority: high +estimate: S +--- + +# Task: Valid strict fixture task + +## Acceptance Criteria +- [x] Current repo state has been inspected before implementation starts. +- [x] The delivered change is represented in Delano runtime assets, project contracts, validation, fixtures, or docs as appropriate. +- [x] The change is validated with the smallest meaningful command or fixture. +- [x] Evidence is recorded in this task or a task update before the task is marked done. + +## Evidence Log +- 2026-04-29T00:10:00Z: Inspected fixture project, added represented runtime fixture content, and validation passed: fixture validator smoke. diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..fab7ea2 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "node \"$(git rev-parse --show-toplevel)/.agents/hooks/codex-session-status.js\"", + "timeout": 5, + "statusMessage": "Loading Delano open projects" + } + ] + } + ] + } +} diff --git a/.delano/README.md b/.delano/README.md new file mode 100644 index 0000000..7cd7e1c --- /dev/null +++ b/.delano/README.md @@ -0,0 +1,7 @@ +# Optional Delano UI Layer + +This folder is reserved for optional presentation tooling. + +Policy: +- `.project` remains source of truth. +- `.delano` must not become process truth. diff --git a/.delano/viewer/README.md b/.delano/viewer/README.md new file mode 100644 index 0000000..014ede6 --- /dev/null +++ b/.delano/viewer/README.md @@ -0,0 +1,20 @@ +# Delano Read-Only Viewer + +Minimal local frontend for browsing `.project` markdown contracts. + +- Read-only: serves files from `.project` and does not write delivery state. +- Default starting URL: `http://127.0.0.1:3977` +- Override starting port: `DELANO_VIEWER_PORT=3987 npm run viewer` +- Multiple viewers can run at once. If the starting port is already in use, the viewer tries the next available port and prints the actual URL. + +Run from the repository root: + +```bash +npm run viewer +``` + +The viewer indexes `.project/context/**/*.md`, `.project/templates/**/*.md`, and `.project/projects/**/*.md`. It derives artifact roles (`spec`, `plan`, `workstream`, `task`, `progress`, `decision`, `context`, `template`), status fields, task/workstream relationships, relationship-like wikilinks, snippets, and renders markdown in a Tolaria-inspired read-only pane layout. + +Project folders get a right-side outline for the spec, plan, decisions/progress, workstreams, and tasks. Selecting a workstream focuses the list on that workstream and its subtasks. Context and template folders keep filters scoped to the roles/statuses that actually exist in the selected folder. + +The reader stays read-only, but includes convenience buttons to open the selected markdown file's containing folder in the system explorer or open the file in VS Code. These actions are guarded so they only target markdown files inside `.project`. diff --git a/.delano/viewer/public/app.js b/.delano/viewer/public/app.js new file mode 100644 index 0000000..74431ee --- /dev/null +++ b/.delano/viewer/public/app.js @@ -0,0 +1,830 @@ +const state = { index: null, project: 'context', doc: null, query: '', status: 'all', role: 'all', workstream: null, outlineOpen: false, sortBy: 'path', sortDir: 'asc' }; + +const SORT_FIELDS = [ + { value: 'path', label: 'Path' }, + { value: 'title', label: 'Title' }, + { value: 'updated', label: 'Updated' }, + { value: 'status', label: 'Status' }, + { value: 'role', label: 'Role' }, + { value: 'taskId', label: 'Task ID' }, +]; + +function sortFieldExists(field) { + return SORT_FIELDS.some((f) => f.value === field); +} + +function getSortValue(doc, field) { + switch (field) { + case 'title': return (doc.title || '').toLowerCase(); + case 'path': return (doc.path || '').toLowerCase(); + case 'updated': return doc.updated || ''; + case 'status': return (doc.status || '').toLowerCase(); + case 'role': return (doc.role || '').toLowerCase(); + case 'taskId': return doc.taskId || ''; + default: return ''; + } +} + +function compareDocs(a, b, field, dir) { + const va = getSortValue(a, field); + const vb = getSortValue(b, field); + const aEmpty = va === '' || va == null; + const bEmpty = vb === '' || vb == null; + // Empties always sort to the end regardless of direction. + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true, sensitivity: 'base' }); + return dir === 'desc' ? -cmp : cmp; +} + +const $ = (sel) => document.querySelector(sel); +const escapeHtml = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +const titleCase = (s) => String(s || '').replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + +const COPY_ICON_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true"><rect x="5" y="2" width="9" height="10" rx="1.6"/><path d="M11 12v1.4A1.6 1.6 0 0 1 9.4 15H2.6A1.6 1.6 0 0 1 1 13.4V6.6A1.6 1.6 0 0 1 2.6 5H4"/></svg>'; + +const copyRegistry = new Map(); +let copyCounter = 0; + +function resetCopyRegistry() { + copyRegistry.clear(); + copyCounter = 0; +} + +function normalizeCopyValue(value) { + if (value == null) return ''; + if (Array.isArray(value)) return value.length ? value.join(', ') : ''; + if (typeof value === 'boolean' || typeof value === 'number') return String(value); + return String(value); +} + +function registerCopy(value) { + const text = normalizeCopyValue(value); + if (text === '') return null; + const id = `c${++copyCounter}`; + copyRegistry.set(id, text); + return id; +} + +function copyButton(value, label, extraClass = '') { + const id = registerCopy(value); + if (!id) return ''; + const safeLabel = escapeHtml(label || 'Copy value'); + const cls = ['copy-btn', extraClass].filter(Boolean).join(' '); + return `<button type="button" class="${cls}" data-copy="${id}" aria-label="${safeLabel}" title="${safeLabel}">${COPY_ICON_SVG}</button>`; +} + +async function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + try { await navigator.clipboard.writeText(text); return true; } catch (_) { /* fall through */ } + } + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.top = '-1000px'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (_) { ok = false; } + document.body.removeChild(ta); + return ok; +} + +function announceCopy(label) { + const live = document.getElementById('copy-live'); + if (!live) return; + live.textContent = ''; + setTimeout(() => { live.textContent = `Copied ${label || 'value'}`; }, 10); +} + +function attachCopyDelegation() { + document.addEventListener('click', async (event) => { + const btn = event.target.closest('[data-copy]'); + if (!btn) return; + const id = btn.getAttribute('data-copy'); + const value = copyRegistry.get(id); + if (value == null) return; + event.preventDefault(); + event.stopPropagation(); + const ok = await copyText(value); + if (!ok) return; + + announceCopy(btn.getAttribute('aria-label') || 'value'); + + // Prefer swapping a dedicated label span so adjacent icons survive the swap. + const labelEl = btn.querySelector('.action-label'); + const hasInlineSvg = !!btn.querySelector('svg'); + const wasCopied = btn.classList.contains('copied'); + + // Cancel any pending restore from an earlier click on the same button. + if (btn._copyResetId) { + clearTimeout(btn._copyResetId); + btn._copyResetId = null; + } + + if (!wasCopied) { + if (labelEl) { + btn._copyOriginalLabel = labelEl.textContent; + labelEl.textContent = 'Copied'; + } else if (!hasInlineSvg) { + btn._copyOriginalText = btn.textContent; + btn.textContent = 'Copied'; + } + } + btn.classList.add('copied'); + + btn._copyResetId = setTimeout(() => { + btn.classList.remove('copied'); + if (labelEl && typeof btn._copyOriginalLabel === 'string') { + labelEl.textContent = btn._copyOriginalLabel; + delete btn._copyOriginalLabel; + } + if (typeof btn._copyOriginalText === 'string') { + btn.textContent = btn._copyOriginalText; + delete btn._copyOriginalText; + } + btn._copyResetId = null; + }, 1400); + }, true); +} + +function setMoreOpen(open) { + const dropdown = document.querySelector('.tab-dropdown'); + const moreBtn = document.querySelector('[data-tab-more]'); + if (!dropdown || !moreBtn) return; + dropdown.setAttribute('data-open', open ? 'true' : 'false'); + moreBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); +} + +function attachTabDropdownDelegation() { + document.addEventListener('click', (event) => { + const moreBtn = event.target.closest('[data-tab-more]'); + if (moreBtn) { + event.preventDefault(); + event.stopPropagation(); + const dropdown = document.querySelector('.tab-dropdown'); + const isOpen = dropdown && dropdown.getAttribute('data-open') === 'true'; + setMoreOpen(!isOpen); + return; + } + const dropdown = document.querySelector('.tab-dropdown'); + if (!dropdown || dropdown.getAttribute('data-open') !== 'true') return; + if (!event.target.closest('.tab-dropdown')) { + setMoreOpen(false); + } + }, true); + + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + const dropdown = document.querySelector('.tab-dropdown'); + if (dropdown && dropdown.getAttribute('data-open') === 'true') { + setMoreOpen(false); + const moreBtn = document.querySelector('[data-tab-more]'); + if (moreBtn) moreBtn.focus(); + } + }); +} + +function applyTabOverflow() { + const tabsContainer = document.querySelector('.tabs'); + if (!tabsContainer) return; + const moreBtn = tabsContainer.querySelector('.tab-more'); + if (!moreBtn) return; + const allTabs = [...tabsContainer.querySelectorAll('.tab')]; + + // Reset state to measure honestly. + allTabs.forEach((t) => t.classList.remove('overflow-hidden')); + moreBtn.classList.remove('hidden'); + moreBtn.classList.remove('has-active'); + + // Bail out when the tab row is configured to wrap (mobile breakpoint). + const flexWrap = window.getComputedStyle(tabsContainer).flexWrap; + if (flexWrap === 'wrap' || flexWrap === 'wrap-reverse') { + moreBtn.classList.add('hidden'); + return; + } + + const containerWidth = tabsContainer.clientWidth; + const gap = 6; + const moreWidth = moreBtn.getBoundingClientRect().width; + const widths = allTabs.map((t) => t.getBoundingClientRect().width); + + // Total width of all tabs (without the More button). + let total = 0; + for (let i = 0; i < allTabs.length; i++) { + total += widths[i] + (i > 0 ? gap : 0); + } + if (total <= containerWidth) { + moreBtn.classList.add('hidden'); + return; + } + + // Pinned tabs are always visible; subtract their width from the container budget upfront. + const pinnedWidth = allTabs.reduce((acc, t, i) => ( + t.classList.contains('tab-fixed') ? acc + widths[i] + (acc > 0 ? gap : 0) : acc + ), 0); + const budget = containerWidth - moreWidth - gap - pinnedWidth - (pinnedWidth > 0 ? gap : 0); + + let used = 0; + let activeHidden = false; + let truncated = false; + + for (let i = 0; i < allTabs.length; i++) { + const tab = allTabs[i]; + if (tab.classList.contains('tab-fixed')) continue; + if (truncated) { + tab.classList.add('overflow-hidden'); + if (tab.classList.contains('active')) activeHidden = true; + continue; + } + const w = widths[i] + (used > 0 ? gap : 0); + if (used + w > budget) { + truncated = true; + tab.classList.add('overflow-hidden'); + if (tab.classList.contains('active')) activeHidden = true; + continue; + } + used += w; + } + + if (!truncated) { + moreBtn.classList.add('hidden'); + } + moreBtn.classList.toggle('has-active', activeHidden); +} + +function scheduleTabOverflow() { + requestAnimationFrame(() => requestAnimationFrame(applyTabOverflow)); +} + +let tabResizeObserver = null; +function watchTabsResize() { + if (tabResizeObserver || !('ResizeObserver' in window)) return; + tabResizeObserver = new ResizeObserver(() => applyTabOverflow()); + tabResizeObserver.observe(document.body); +} + +function statusClass(status) { return status ? `pill ${String(status).toLowerCase()}` : 'pill'; } +function byPath(path) { return state.index.docs.find((d) => d.path === path); } +function currentProject() { return state.index.projects.find((p) => p.slug === state.project) || state.index.projects[0]; } +function projectDocs() { return currentProject().docs.map(byPath).filter(Boolean); } +function isProjectGroup() { return Boolean(currentProject().outline); } +function availableStatuses() { return [...new Set(projectDocs().map((d) => d.status).filter(Boolean).map((s) => String(s).toLowerCase()))]; } +function availableRoles() { return [...new Set(projectDocs().map((d) => d.role).filter(Boolean))]; } + +function currentDocs() { + const docs = projectDocs(); + const filtered = docs.filter((doc) => { + const q = state.query.toLowerCase(); + const haystack = [doc.title, doc.path, doc.snippet, doc.role, JSON.stringify(doc.frontmatter)].join(' ').toLowerCase(); + const matchesQ = !q || haystack.includes(q); + const matchesStatus = state.status === 'all' || String(doc.status || '').toLowerCase() === state.status; + const matchesRole = state.role === 'all' || doc.role === state.role; + const matchesWorkstream = !state.workstream || doc.path === state.workstream || doc.workstreamPath === state.workstream; + return matchesQ && matchesStatus && matchesRole && matchesWorkstream; + }); + const sortField = sortFieldExists(state.sortBy) ? state.sortBy : 'path'; + const sortDir = state.sortDir === 'desc' ? 'desc' : 'asc'; + filtered.sort((a, b) => compareDocs(a, b, sortField, sortDir)); + return filtered; +} + +function inlineMd(text) { + let s = escapeHtml(text); + s = s.replace(/`([^`]+)`/g, '<code>$1</code>'); + s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); + s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?=[^*\w]|$)/g, '$1<em>$2</em>'); + s = s.replace(/\[\[([^\]]+)\]\]/g, '<span class="wikilink" data-target="$1">$1</span>'); + s = s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:|\.\.?\/|\/)[^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>'); + return s; +} + +function isTableSeparator(line) { + if (!line) return false; + return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line); +} + +function isBlockStart(line, nextLine) { + if (!line) return false; + if (/^\s*```/.test(line)) return true; + if (/^#{1,6}\s+/.test(line)) return true; + if (/^\s*-{3,}\s*$/.test(line)) return true; + if (/^\s*>/.test(line)) return true; + if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) return true; + if (line.includes('|') && nextLine && isTableSeparator(nextLine)) return true; + return false; +} + +function renderCodeBlock(text, lang) { + const langClass = lang ? ` class="lang-${escapeHtml(lang)}"` : ''; + const langBadge = lang ? `<span class="code-lang" aria-hidden="true">${escapeHtml(lang)}</span>` : ''; + const padClass = lang ? ' has-lang' : ''; + return `<pre class="code${padClass}">${langBadge}${copyButton(text, lang ? `Copy ${lang} code` : 'Copy code', 'pre-copy')}<code${langClass}>${escapeHtml(text)}</code></pre>`; +} + +function renderMermaidFallback(source) { + return `<figure class="mermaid-block"> + <figcaption>Mermaid diagram (source view)</figcaption> + <pre class="code has-lang">${`<span class="code-lang" aria-hidden="true">mermaid</span>`}${copyButton(source, 'Copy mermaid source', 'pre-copy')}<code class="lang-mermaid">${escapeHtml(source)}</code></pre> + </figure>`; +} + +function renderTable(tableLines) { + const parseRow = (line) => { + let s = line.trim(); + if (s.startsWith('|')) s = s.slice(1); + if (s.endsWith('|')) s = s.slice(0, -1); + return s.split('|').map((c) => c.trim()); + }; + const aligns = parseRow(tableLines[1]).map((s) => { + if (/^:-+:$/.test(s)) return 'center'; + if (/^-+:$/.test(s)) return 'right'; + if (/^:-+$/.test(s)) return 'left'; + return null; + }); + const headers = parseRow(tableLines[0]); + const headerHtml = `<thead><tr>${headers.map((h, j) => { + const a = aligns[j] ? ` style="text-align:${aligns[j]}"` : ''; + return `<th${a}>${inlineMd(h)}</th>`; + }).join('')}</tr></thead>`; + const rowsHtml = tableLines.slice(2).map((line) => { + const cells = parseRow(line); + return `<tr>${cells.map((c, j) => { + const a = aligns[j] ? ` style="text-align:${aligns[j]}"` : ''; + return `<td${a}>${inlineMd(c)}</td>`; + }).join('')}</tr>`; + }).join(''); + return `<div class="table-wrap"><table>${headerHtml}<tbody>${rowsHtml}</tbody></table></div>`; +} + +function parseList(lines, start, baseIndent) { + const items = []; + let i = start; + let listType = null; + let isTaskList = false; + + while (i < lines.length) { + const line = lines[i]; + const m = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/); + if (!m) break; + const indent = m[1].length; + if (indent !== baseIndent) break; + const isOrdered = /^\d+\./.test(m[2]); + if (listType === null) listType = isOrdered ? 'ol' : 'ul'; + else if ((listType === 'ol') !== isOrdered) break; + + const content = m[3]; + const taskMatch = content.match(/^\[([ xX])\]\s+(.*)$/); + let itemHtml; + let itemClasses = []; + if (taskMatch) { + isTaskList = true; + const checked = taskMatch[1].toLowerCase() === 'x'; + itemClasses.push('task-item'); + if (checked) itemClasses.push('checked'); + const checkSvg = checked + ? '<svg viewBox="0 0 14 14" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3,7 6,10 11,4"/></svg>' + : ''; + itemHtml = `<span class="task-marker" aria-hidden="true">${checkSvg}</span><span class="task-text">${inlineMd(taskMatch[2])}</span>`; + } else { + itemHtml = inlineMd(content); + } + + i++; + + let nestedHtml = ''; + while (i < lines.length) { + const nextLine = lines[i]; + if (!nextLine.trim()) { + if (i + 1 < lines.length) { + const peek = lines[i + 1].match(/^(\s*)([-*+]|\d+\.)\s+/); + if (peek && peek[1].length > baseIndent) { i++; continue; } + } + break; + } + const nextMatch = nextLine.match(/^(\s*)([-*+]|\d+\.)\s+/); + if (nextMatch && nextMatch[1].length > baseIndent) { + const nested = parseList(lines, i, nextMatch[1].length); + nestedHtml += nested.html; + i = nested.next; + } else { + break; + } + } + + const cls = itemClasses.length ? ` class="${itemClasses.join(' ')}"` : ''; + items.push(`<li${cls}>${itemHtml}${nestedHtml}</li>`); + } + + const tag = listType || 'ul'; + const cls = isTaskList ? ' class="task-list"' : ''; + return { html: `<${tag}${cls}>${items.join('')}</${tag}>`, next: i }; +} + +function parseBlocks(lines) { + const out = []; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + const fence = line.match(/^\s*```(.*)$/); + if (fence) { + const lang = (fence[1] || '').trim(); + const codeLines = []; + i++; + while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { + codeLines.push(lines[i]); + i++; + } + i++; + const codeText = codeLines.join('\n'); + out.push(lang.toLowerCase() === 'mermaid' ? renderMermaidFallback(codeText) : renderCodeBlock(codeText, lang)); + continue; + } + + if (!line.trim()) { i++; continue; } + + if (/^\s*-{3,}\s*$/.test(line) || /^\s*\*{3,}\s*$/.test(line)) { + out.push('<hr class="rule"/>'); + i++; + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/); + if (heading) { + const level = heading[1].length; + out.push(`<h${level}>${inlineMd(heading[2])}</h${level}>`); + i++; + continue; + } + + if (line.includes('|') && i + 1 < lines.length && isTableSeparator(lines[i + 1])) { + const tableLines = [lines[i], lines[i + 1]]; + i += 2; + while (i < lines.length && lines[i].trim() && lines[i].includes('|')) { + tableLines.push(lines[i]); + i++; + } + out.push(renderTable(tableLines)); + continue; + } + + if (/^\s*>/.test(line)) { + const quoteLines = []; + while (i < lines.length && /^\s*>/.test(lines[i])) { + quoteLines.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + out.push(`<blockquote>${parseBlocks(quoteLines)}</blockquote>`); + continue; + } + + if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) { + const indent = (line.match(/^(\s*)/)[1] || '').length; + const list = parseList(lines, i, indent); + out.push(list.html); + i = list.next; + continue; + } + + const paraLines = []; + while (i < lines.length && lines[i].trim() && !isBlockStart(lines[i], lines[i + 1])) { + paraLines.push(lines[i]); + i++; + } + if (paraLines.length) { + out.push(`<p>${inlineMd(paraLines.join(' '))}</p>`); + } else { + i++; + } + } + return out.join('\n'); +} + +function renderMarkdown(markdown) { + const body = markdown.replace(/^---[\s\S]*?\n---\r?\n/, ''); + const lines = body.split(/\r?\n/); + const html = parseBlocks(lines); + return html || '<p class="empty">This document is empty.</p>'; +} + +async function loadDoc(path) { + const res = await fetch(`/api/doc?path=${encodeURIComponent(path)}`); + state.doc = await res.json(); + render(); +} + +const ELLIPSIS_ICON_SVG = '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><circle cx="3.2" cy="8" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12.8" cy="8" r="1.4"/></svg>'; + +function renderTabs() { + const tabs = state.index.projects.map((p) => { + const classes = ['tab']; + if (p.slug === state.project) classes.push('active'); + if (p.pinned) classes.push('tab-fixed'); + return `<button class="${classes.join(' ')}" data-project="${p.slug}"> + <span>${escapeHtml(p.title)}</span> + <span class="count">${p.docs.length}</span> + </button>`; + }).join(''); + + const dropdownProjects = state.index.projects.filter((p) => !p.pinned); + const dropdownItems = dropdownProjects.map((p) => ( + `<button class="dropdown-item ${p.slug === state.project ? 'active' : ''}" data-project="${p.slug}" role="menuitem"> + <span>${escapeHtml(p.title)}</span> + <span class="count">${p.docs.length}</span> + </button>` + )).join(''); + + const dropdown = dropdownProjects.length + ? `<div class="tab-dropdown" role="menu" aria-label="More projects">${dropdownItems}</div>` + : ''; + + return `${tabs} + <button type="button" class="tab-more hidden" data-tab-more aria-label="More projects" aria-expanded="false" aria-haspopup="true">${ELLIPSIS_ICON_SVG}</button> + ${dropdown}`; +} + +function renderFilters() { + const roles = availableRoles(); + const statuses = availableStatuses(); + const roleLabels = { context: 'context', template: 'templates', spec: 'spec', plan: 'plan', workstream: 'workstreams', task: 'tasks', decision: 'decisions', progress: 'progress' }; + const roleButtons = roles.map((r) => `<button class="filter ${state.role === r ? 'active' : ''}" data-role="${r}">${roleLabels[r] || r}</button>`).join(''); + const statusButtons = statuses.map((s) => `<button class="filter ${state.status === s ? 'active' : ''}" data-status="${s}">${s}</button>`).join(''); + + const sortOptions = SORT_FIELDS.map((f) => ( + `<option value="${f.value}" ${state.sortBy === f.value ? 'selected' : ''}>${escapeHtml(f.label)}</option>` + )).join(''); + const dirIsAsc = state.sortDir !== 'desc'; + const dirIcon = dirIsAsc + ? '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 11l4-6 4 6"/></svg>' + : '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5l4 6 4-6"/></svg>'; + const dirLabel = dirIsAsc ? 'Sort ascending — click to flip to descending' : 'Sort descending — click to flip to ascending'; + + return `<div class="filter-group"> + <span class="filter-label">Show</span> + <button class="filter ${state.role === 'all' ? 'active' : ''}" data-role="all">all</button> + ${roleButtons} + </div> + ${statuses.length + ? `<div class="filter-group"><span class="filter-label">Status</span><button class="filter ${state.status === 'all' ? 'active' : ''}" data-status="all">all</button>${statusButtons}</div>` + : '<div class="filter-note">No status filters for this folder.</div>'} + <div class="filter-group sort-group"> + <span class="filter-label">Sort</span> + <select class="sort-field" data-sort-field aria-label="Sort field">${sortOptions}</select> + <button type="button" class="sort-dir" data-sort-dir aria-label="${escapeHtml(dirLabel)}" title="${escapeHtml(dirLabel)}" data-direction="${dirIsAsc ? 'asc' : 'desc'}">${dirIcon}</button> + </div> + ${state.workstream ? '<button class="workstream-scope" data-clear-workstream>Showing selected workstream and subtasks x</button>' : ''}`; +} + +function renderList() { + const docs = currentDocs(); + const items = docs.map((doc, i) => ( + `<article class="doc reveal ${state.doc?.path === doc.path ? 'active' : ''}" style="--index:${i}" data-doc="${doc.path}"> + <div class="doc-title"> + <span>${escapeHtml(doc.title)}</span> + ${doc.status ? `<span class="${statusClass(doc.status)}">${escapeHtml(doc.status)}</span>` : `<span class="pill">${escapeHtml(titleCase(doc.role))}</span>`} + </div> + <div class="doc-path">${escapeHtml(doc.path)}</div> + <div class="doc-snippet">${escapeHtml(doc.snippet)}</div> + </article>` + )).join(''); + + return `<main class="list reveal"> + <input class="search" placeholder="Search this ${isProjectGroup() ? 'project' : 'folder'}..." value="${escapeHtml(state.query)}" /> + <div class="filters">${renderFilters()}</div> + ${items || '<div class="empty">No documents match this filter.</div>'} + </main>`; +} + +function renderReader() { + const doc = state.doc; + if (!doc) return '<section class="reader reveal"><div class="empty">Select a document.</div></section>'; + const props = Object.entries(doc.frontmatter || {}); + const properties = props.length ? `<div class="properties">${props.map(([k, v]) => { + const display = normalizeCopyValue(v); + return `<div class="prop-key">${escapeHtml(k)}</div><div class="prop-value"><span class="prop-text">${escapeHtml(display)}</span>${copyButton(v, `Copy ${k}`)}</div>`; + }).join('')}</div>` : ''; + + const markdownCopyId = registerCopy(doc.markdown); + + return `<section class="reader reveal"> + <div class="reader-inner"> + <header class="reader-head"> + <div class="reader-top"> + <div> + <div class="eyebrow">${escapeHtml(titleCase(doc.role))}</div> + <h1>${escapeHtml(doc.title)}</h1> + </div> + </div> + <div class="meta"> + <span class="pill path-pill">${escapeHtml(doc.path)}</span> + ${copyButton(doc.path, 'Copy path')} + ${doc.status ? `<span class="${statusClass(doc.status)}">${escapeHtml(doc.status)}</span>` : ''} + <span class="pill">updated ${escapeHtml(String(doc.updated).slice(0, 10))}</span> + </div> + <div class="reader-actions"> + <button class="action" data-open="explorer" title="Open containing folder in system explorer" aria-label="Open in system explorer"> + <img class="action-icon" src="/explorer.svg" alt="" aria-hidden="true" /> + <span class="action-label">Open</span> + </button> + <button class="action" data-open="code" title="Open this markdown file in VS Code" aria-label="Open in VS Code"> + <img class="action-icon" src="/vscode.svg" alt="" aria-hidden="true" /> + <span class="action-label">Open</span> + </button> + ${markdownCopyId ? `<button type="button" class="action" data-copy="${markdownCopyId}" title="Copy the rendered markdown body" aria-label="Copy markdown"> + <img class="action-icon action-icon-md" src="/markdown.svg" alt="" aria-hidden="true" /> + <span class="action-label">Copy</span> + </button>` : ''} + </div> + <div class="open-feedback" aria-live="polite"></div> + ${properties} + </header> + <article class="markdown">${renderMarkdown(doc.markdown)}</article> + </div> + </section>`; +} + +async function openCurrentDoc(target) { + if (!state.doc) return; + const feedback = $('.open-feedback'); + try { + const res = await fetch(`/api/open?target=${encodeURIComponent(target)}&path=${encodeURIComponent(state.doc.path)}`, { method: 'POST' }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) throw new Error(data.error || 'Open action failed.'); + if (feedback) feedback.textContent = target === 'code' ? 'Opened in VS Code.' : 'Opened in system explorer.'; + } catch (error) { + if (feedback) feedback.textContent = error.message || String(error); + } +} + +function outlineLink(path, label, extra = '') { + if (!path) return ''; + const doc = byPath(path); + const active = state.doc?.path === path ? 'active' : ''; + return `<button class="outline-link ${active}" data-doc="${path}"><span>${escapeHtml(label || doc?.title || path)}</span>${extra}</button>`; +} + +function renderProjectOutline() { + const project = currentProject(); + if (!project.outline) { + return `<aside class="outline reveal"> + <div class="outline-title">Folder guide</div> + <p class="outline-help">${project.slug === 'context' ? 'Context is repo-level background. Status filters stay hidden because these documents are not delivery tasks.' : 'Templates are reusable contracts. Status filters stay hidden unless this folder contains statuses.'}</p> + </aside>`; + } + + const outline = project.outline; + const labelWithId = (id, title) => { + if (!id) return title; + return String(title || '').startsWith(id) ? title : `${id} ${title}`; + }; + const taskLink = (path) => { + const task = byPath(path); + const status = task?.status ? `<span class="${statusClass(task.status)}">${escapeHtml(task.status)}</span>` : ''; + return outlineLink(path, `${task?.taskId ? `${task.taskId} ` : ''}${task?.title || path}`, status); + }; + const decisions = outline.decisions.map((p) => outlineLink(p, byPath(p)?.title || 'Decisions')).join(''); + const progressLink = outline.progress.length + ? outlineLink(outline.progress[0], `Progress log (${outline.progress.length})`) + : ''; + + const workstreams = outline.workstreams.map((ws) => ( + `<div class="workstream-block ${state.workstream === ws.path ? 'active' : ''}"> + <button class="outline-link workstream-pick ${state.doc?.path === ws.path ? 'active' : ''}" data-workstream="${ws.path}" data-doc="${ws.path}"> + <span>${escapeHtml(labelWithId(ws.id, ws.title))}</span> + ${ws.status ? `<span class="${statusClass(ws.status)}">${escapeHtml(ws.status)}</span>` : `<span class="count">${ws.tasks.length}</span>`} + </button> + ${state.workstream === ws.path ? `<div class="subtasks">${ws.tasks.map(taskLink).join('') || '<div class="empty small">No subtasks linked yet.</div>'}</div>` : ''} + </div>` + )).join(''); + + return `<aside class="outline reveal"> + <div class="outline-title">Project outline</div> + <p class="outline-help">Select a workstream to focus the list and reveal its subtasks.</p> + <div class="outline-section"> + <div class="outline-label">Core</div> + ${outlineLink(outline.spec, 'Spec')} + ${outlineLink(outline.plan, 'Plan')} + ${decisions} + ${progressLink} + </div> + <div class="outline-section"> + <div class="outline-label">Workstreams and Tasks</div> + ${workstreams} + ${outline.unassignedTasks.length ? `<div class="outline-label">Unassigned tasks</div>${outline.unassignedTasks.map(taskLink).join('')}` : ''} + </div> + </aside>`; +} + +function resetGroupFilters() { + state.status = 'all'; + state.role = 'all'; + state.workstream = null; +} + +function prepareReveal() { + const nodes = [...document.querySelectorAll('.reveal')]; + if (!('IntersectionObserver' in window)) { + nodes.forEach((node) => node.classList.add('visible')); + return; + } + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('visible'); + observer.unobserve(entry.target); + } + }); + }, { threshold: 0.08 }); + nodes.forEach((node) => observer.observe(node)); +} + +function render() { + resetCopyRegistry(); + const outlineClass = state.outlineOpen ? 'outline-open' : ''; + $('#app').innerHTML = `<div class="viewer-frame ${outlineClass}"> + <header class="top-bar"> + <div class="brand-mark">Delano</div> + <nav class="tabs">${renderTabs()}</nav> + <button class="outline-toggle" data-outline-toggle>${state.outlineOpen ? 'Hide outline' : 'Show outline'}</button> + </header> + <div class="shell">${renderList()}${renderReader()}${renderProjectOutline()}</div> + </div>`; + + document.querySelectorAll('[data-project]').forEach((el) => el.onclick = () => { + state.project = el.dataset.project; + state.doc = null; + resetGroupFilters(); + const proj = currentProject(); + // Auto-open the outline panel when entering a project view; collapse on context/templates. + state.outlineOpen = !!(proj && proj.outline); + // Project views land on the spec by default; fall back to the first sorted doc otherwise. + if (proj && proj.outline && proj.outline.spec) { + loadDoc(proj.outline.spec); + return; + } + const first = currentDocs()[0]; + if (first) loadDoc(first.path); else render(); + }); + document.querySelectorAll('[data-doc]').forEach((el) => el.onclick = () => loadDoc(el.dataset.doc)); + document.querySelectorAll('[data-status]').forEach((el) => el.onclick = () => { state.status = el.dataset.status; render(); }); + document.querySelectorAll('[data-role]').forEach((el) => el.onclick = () => { + state.role = el.dataset.role; + state.workstream = null; + if (state.role !== 'all') { + const firstInRole = currentDocs().find((doc) => doc.role === state.role); + if (firstInRole) { + if (state.role === 'workstream') state.workstream = firstInRole.path; + loadDoc(firstInRole.path); + return; + } + } + render(); + }); + document.querySelectorAll('[data-workstream]').forEach((el) => el.onclick = () => { state.workstream = el.dataset.workstream; state.role = 'all'; loadDoc(el.dataset.doc); }); + document.querySelectorAll('[data-clear-workstream]').forEach((el) => el.onclick = () => { state.workstream = null; render(); }); + document.querySelectorAll('[data-outline-toggle]').forEach((el) => el.onclick = () => { state.outlineOpen = !state.outlineOpen; render(); }); + document.querySelectorAll('[data-open]').forEach((el) => el.onclick = () => openCurrentDoc(el.dataset.open)); + const sortField = document.querySelector('[data-sort-field]'); + if (sortField) sortField.onchange = (e) => { state.sortBy = e.target.value; render(); }; + document.querySelectorAll('[data-sort-dir]').forEach((el) => el.onclick = () => { state.sortDir = state.sortDir === 'desc' ? 'asc' : 'desc'; render(); }); + const search = $('.search'); + if (search) search.oninput = (e) => { + const caret = e.target.selectionStart; + state.query = e.target.value; + render(); + const next = $('.search'); + if (next) { + next.focus(); + try { next.setSelectionRange(caret, caret); } catch (_) { /* non-text input types */ } + } + }; + document.querySelectorAll('.wikilink').forEach((el) => el.onclick = () => { state.query = el.dataset.target; render(); }); + prepareReveal(); + scheduleTabOverflow(); +} + +(async function init() { + attachCopyDelegation(); + attachTabDropdownDelegation(); + watchTabsResize(); + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(() => applyTabOverflow()).catch(() => {}); + } + const res = await fetch('/api/index'); + state.index = await res.json(); + const initialProject = currentProject(); + // If the initial project is a project view, reveal the outline and land on its spec. + if (initialProject && initialProject.outline) { + state.outlineOpen = true; + if (initialProject.outline.spec) { + await loadDoc(initialProject.outline.spec); + return; + } + } + const first = currentDocs()[0] || state.index.docs[0]; + if (first) await loadDoc(first.path); else render(); +})(); diff --git a/.delano/viewer/public/app.jsx b/.delano/viewer/public/app.jsx new file mode 100644 index 0000000..0973b9b --- /dev/null +++ b/.delano/viewer/public/app.jsx @@ -0,0 +1,2478 @@ +const { useState, useEffect, useMemo, useCallback } = React; + +/* ================================================================ + Icons — hairline 1.4px stroke, 24×24 viewBox + ================================================================ */ +const Icon = ({ d, size = 16, fill = "none", stroke = "currentColor" }) => ( + <svg width={size} height={size} viewBox="0 0 24 24" fill={fill} stroke={stroke} + strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> + {typeof d === "string" ? <path d={d} /> : d} + </svg> +); + +const I = { + home: <><path d="M3 11.5 12 4l9 7.5"/><path d="M5 10v10h14V10"/></>, + list: <><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><circle cx="4" cy="6" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="18" r="1"/></>, + block: <><circle cx="12" cy="12" r="8.5"/><path d="M6 6l12 12"/></>, + trend: <><path d="M3 17l6-6 4 4 8-8"/><path d="M14 7h7v7"/></>, + check: <><rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M8 12.5l3 3 5-6"/></>, + checkMark: <path d="M5 12.5l4 4 10-11"/>, + copy: <><rect x="8" y="4" width="11" height="14" rx="1.5"/><path d="M16 18v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2"/></>, + warn: <><path d="M12 3.5 21 19H3z"/><path d="M12 10v4.5"/><circle cx="12" cy="17" r="0.6" fill="currentColor"/></>, + doc: <><path d="M6 3.5h8l4 4V20.5H6z"/><path d="M14 3.5V8h4"/></>, + plan: <><path d="M4 5.5h16"/><path d="M4 12h16"/><path d="M4 18.5h10"/></>, + scale: <><path d="M12 4v16"/><path d="M5 8h14"/><path d="M5 8 3 13h4z"/><path d="M19 8l-2 5h4z"/></>, + clock: <><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></>, + grid: <><rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><rect x="14" y="14" width="6" height="6" rx="1"/></>, + task: <><rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M7 8.5h10"/><path d="M7 13h7"/></>, + folder: <><path d="M3.5 6.5a2 2 0 0 1 2-2h3.5l2 2h7.5a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"/></>, + gear: <><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 0 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 0 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 0 1 4 0v.1a1.7 1.7 0 0 0 1 1.5h.1a1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 0 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></>, + code: <><path d="m9 8-5 4 5 4"/><path d="m15 8 5 4-5 4"/></>, + folderOpen:<><path d="M3 7a2 2 0 0 1 2-2h3l2 2h7a2 2 0 0 1 2 2v1H3z"/><path d="M3 10h18l-2 8a2 2 0 0 1-2 1.5H5a2 2 0 0 1-2-1.5z"/></>, + user: <><circle cx="12" cy="8" r="3.5"/><path d="M5 20c1.5-3.5 4-5 7-5s5.5 1.5 7 5"/></>, + chevR: <path d="m9 6 6 6-6 6"/>, + chevD: <path d="m6 9 6 6 6-6"/>, + chevU: <path d="m6 15 6-6 6 6"/>, + arrowL: <><path d="M19 12H5"/><path d="m12 5-7 7 7 7"/></>, + lock: <><rect x="4.5" y="10.5" width="15" height="10" rx="1.5"/><path d="M8 10.5V7a4 4 0 0 1 8 0v3.5"/></>, + search: <><circle cx="11" cy="11" r="6"/><path d="m20 20-4.3-4.3"/></>, +}; + +/* ================================================================ + Status utilities + ================================================================ */ +const STATUS_TONE = { + "Planned": { dot: "var(--ink-40)" }, + "In Progress": { dot: "var(--accent)" }, + "Complete": { dot: "var(--ok)" }, + "Blocked": { dot: "var(--warn)" }, +}; + +const NAV_STATE_KEY = "delano.viewer.navigation.v1"; +const NAV_STATE_VERSION = 1; +const DEFAULT_WORKSPACE_ROUTE = "workspace-projects"; +const WORKSPACE_PAGE_SIZE = 10; + +function statusLabel(raw) { + if (!raw) return "Planned"; + const s = String(raw).toLowerCase().replace(/[-_]+/g, " ").trim(); + if (s.includes("progress") || s === "active") return "In Progress"; + if (s === "blocked") return "Blocked"; + if (["complete", "done", "approved", "closed"].includes(s)) return "Complete"; + if (["planned", "draft", "ready"].includes(s)) return "Planned"; + return "Planned"; +} + +const StatusChip = ({ children }) => { + const label = statusLabel(children); + const tone = STATUS_TONE[label] || STATUS_TONE["Planned"]; + return ( + <span className="chip"> + <span className="chip-dot" style={{ background: tone.dot }} /> + {label} + </span> + ); +}; + +/* ================================================================ + HTML / text utilities + ================================================================ */ +const escapeHtml = (s) => + String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + +const titleCase = (s) => + String(s || "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + +const normalizeCopyValue = (value) => { + if (value == null) return ""; + if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean).join(", "); + if (typeof value === "boolean" || typeof value === "number") return String(value); + if (typeof value === "object") return JSON.stringify(value); + return String(value).trim(); +}; + +const isCopyableMetaKey = (key) => { + const normalized = String(key || "").toLowerCase(); + return ( + normalized === "id" || + normalized === "slug" || + normalized === "workstream" || + normalized === "depends_on" || + normalized === "conflicts_with" || + /(?:^|_)(?:id|ids)$/.test(normalized) + ); +}; + +const copyLabelFromMetaKey = (key, role) => { + const normalized = String(key || "").toLowerCase(); + if (normalized === "id" && role) return `${titleCase(role)} ID`; + if (normalized === "workstream") return "workstream ID"; + if (normalized === "depends_on") return "dependency IDs"; + if (normalized === "conflicts_with") return "conflict IDs"; + return normalized.replace(/_/g, " ") || "value"; +}; + +async function copyTextToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (_) { + /* fall through */ + } + } + + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.setAttribute("readonly", ""); + textArea.style.position = "fixed"; + textArea.style.top = "-1000px"; + textArea.style.opacity = "0"; + document.body.appendChild(textArea); + textArea.select(); + let ok = false; + try { + ok = document.execCommand("copy"); + } catch (_) { + ok = false; + } + document.body.removeChild(textArea); + return ok; +} + +function announceCopy(label) { + const live = document.getElementById("copy-live"); + if (!live) return; + live.textContent = ""; + window.setTimeout(() => { + live.textContent = `Copied ${label || "value"}`; + }, 10); +} + +const formatShortDateTime = (value) => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +}; + +const stripRepeatedTitle = (title, text) => { + const source = String(text || "").trim(); + const heading = String(title || "").trim(); + if (!source || !heading) return source; + const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return source + .replace(new RegExp(`^#{1,6}\\s+${escaped}\\s*`, "i"), "") + .replace(new RegExp(`^${escaped}\\s+`, "i"), "") + .trim(); +}; + +/* ================================================================ + Markdown rendering (ported from original Delano viewer) + ================================================================ */ +function inlineMd(text) { + let s = escapeHtml(text); + s = s.replace(/`([^`]+)`/g, "<code>$1</code>"); + s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"); + s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?=[^*\w]|$)/g, "$1<em>$2</em>"); + s = s.replace(/\[\[([^\]]+)\]\]/g, '<span class="wikilink">$1</span>'); + s = s.replace( + /\[([^\]]+)\]\(((?:https?:\/\/|mailto:|\.\.?\/|\/)[^)]+)\)/g, + '<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>' + ); + return s; +} + +function isTableSeparator(line) { + return line ? /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line) : false; +} + +function isBlockStart(line, nextLine) { + if (!line) return false; + if (/^\s*```/.test(line)) return true; + if (/^#{1,6}\s+/.test(line)) return true; + if (/^\s*-{3,}\s*$/.test(line)) return true; + if (/^\s*>/.test(line)) return true; + if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) return true; + if (line.includes("|") && nextLine && isTableSeparator(nextLine)) return true; + return false; +} + +function renderCodeBlock(text, lang) { + const langBadge = lang ? `<span class="code-lang">${escapeHtml(lang)}</span>` : ""; + const padClass = lang ? " has-lang" : ""; + return `<pre class="code${padClass}">${langBadge}<code>${escapeHtml(text)}</code></pre>`; +} + +function renderTable(tableLines) { + const parseRow = (line) => { + let s = line.trim(); + if (s.startsWith("|")) s = s.slice(1); + if (s.endsWith("|")) s = s.slice(0, -1); + return s.split("|").map((c) => c.trim()); + }; + const aligns = parseRow(tableLines[1]).map((s) => { + if (/^:-+:$/.test(s)) return "center"; + if (/^-+:$/.test(s)) return "right"; + if (/^:-+$/.test(s)) return "left"; + return null; + }); + const headers = parseRow(tableLines[0]); + const headerHtml = + "<thead><tr>" + + headers.map((h, j) => { + const a = aligns[j] ? ` style="text-align:${aligns[j]}"` : ""; + return `<th${a}>${inlineMd(h)}</th>`; + }).join("") + + "</tr></thead>"; + const rowsHtml = tableLines + .slice(2) + .map((line) => { + const cells = parseRow(line); + return ( + "<tr>" + + cells.map((c, j) => { + const a = aligns[j] ? ` style="text-align:${aligns[j]}"` : ""; + return `<td${a}>${inlineMd(c)}</td>`; + }).join("") + + "</tr>" + ); + }) + .join(""); + return `<div class="table-wrap"><table>${headerHtml}<tbody>${rowsHtml}</tbody></table></div>`; +} + +function parseList(lines, start, baseIndent) { + const items = []; + let i = start; + let listType = null; + let isTaskList = false; + + while (i < lines.length) { + const line = lines[i]; + const m = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/); + if (!m) break; + const indent = m[1].length; + if (indent !== baseIndent) break; + const isOrdered = /^\d+\./.test(m[2]); + if (listType === null) listType = isOrdered ? "ol" : "ul"; + else if ((listType === "ol") !== isOrdered) break; + + const content = m[3]; + const taskMatch = content.match(/^\[([ xX])\]\s+(.*)$/); + let itemHtml; + const itemClasses = []; + if (taskMatch) { + isTaskList = true; + const checked = taskMatch[1].toLowerCase() === "x"; + itemClasses.push("task-item"); + if (checked) itemClasses.push("checked"); + const checkSvg = checked + ? '<svg viewBox="0 0 14 14" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3,7 6,10 11,4"/></svg>' + : ""; + itemHtml = `<span class="task-marker">${checkSvg}</span><span class="task-text">${inlineMd(taskMatch[2])}</span>`; + } else { + itemHtml = inlineMd(content); + } + + i++; + let nestedHtml = ""; + while (i < lines.length) { + const nextLine = lines[i]; + if (!nextLine.trim()) { + if (i + 1 < lines.length) { + const peek = lines[i + 1].match(/^(\s*)([-*+]|\d+\.)\s+/); + if (peek && peek[1].length > baseIndent) { i++; continue; } + } + break; + } + const nextMatch = nextLine.match(/^(\s*)([-*+]|\d+\.)\s+/); + if (nextMatch && nextMatch[1].length > baseIndent) { + const nested = parseList(lines, i, nextMatch[1].length); + nestedHtml += nested.html; + i = nested.next; + } else { + break; + } + } + + const cls = itemClasses.length ? ` class="${itemClasses.join(" ")}"` : ""; + items.push(`<li${cls}>${itemHtml}${nestedHtml}</li>`); + } + + const tag = listType || "ul"; + const cls = isTaskList ? ' class="task-list"' : ""; + return { html: `<${tag}${cls}>${items.join("")}</${tag}>`, next: i }; +} + +function parseBlocks(lines) { + const out = []; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + const fence = line.match(/^\s*```(.*)$/); + if (fence) { + const lang = (fence[1] || "").trim(); + const codeLines = []; + i++; + while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { codeLines.push(lines[i]); i++; } + i++; + out.push(renderCodeBlock(codeLines.join("\n"), lang)); + continue; + } + + if (!line.trim()) { i++; continue; } + + if (/^\s*-{3,}\s*$/.test(line) || /^\s*\*{3,}\s*$/.test(line)) { + out.push("<hr/>"); + i++; + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/); + if (heading) { + const level = heading[1].length; + out.push(`<h${level}>${inlineMd(heading[2])}</h${level}>`); + i++; + continue; + } + + if (line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) { + const tableLines = [lines[i], lines[i + 1]]; + i += 2; + while (i < lines.length && lines[i].trim() && lines[i].includes("|")) { tableLines.push(lines[i]); i++; } + out.push(renderTable(tableLines)); + continue; + } + + if (/^\s*>/.test(line)) { + const quoteLines = []; + while (i < lines.length && /^\s*>/.test(lines[i])) { quoteLines.push(lines[i].replace(/^\s*>\s?/, "")); i++; } + out.push(`<blockquote>${parseBlocks(quoteLines)}</blockquote>`); + continue; + } + + if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) { + const indent = (line.match(/^(\s*)/)[1] || "").length; + const list = parseList(lines, i, indent); + out.push(list.html); + i = list.next; + continue; + } + + const paraLines = []; + while (i < lines.length && lines[i].trim() && !isBlockStart(lines[i], lines[i + 1])) { + paraLines.push(lines[i]); + i++; + } + if (paraLines.length) { + out.push(`<p>${inlineMd(paraLines.join(" "))}</p>`); + } else { + i++; + } + } + return out.join("\n"); +} + +function renderMarkdown(markdown) { + const body = markdown.replace(/^---[\s\S]*?\n---\r?\n/, ""); + return parseBlocks(body.split(/\r?\n/)) || '<p class="empty-state">This document is empty.</p>'; +} + +/* ================================================================ + Data helpers + ================================================================ */ +function byPath(docs, path) { + return docs.find((d) => d.path === path); +} + +function getProjectData(index, slug) { + if (!index) return { project: null, docs: [] }; + const project = index.projects.find((p) => p.slug === slug) || index.projects[0]; + if (!project) return { project: null, docs: [] }; + const docs = project.docs.map((p) => byPath(index.docs, p)).filter(Boolean); + return { project, docs }; +} + +function computeHealth(docs) { + const tasks = docs.filter((d) => d.role === "task"); + if (!tasks.length) return { pct: 0, label: "No tasks" }; + const done = tasks.filter((d) => statusLabel(d.status) === "Complete").length; + const pct = Math.round((done / tasks.length) * 100); + const remaining = tasks.length - done; + return { pct, label: remaining > 0 ? `${remaining} of ${tasks.length} incomplete` : "All tasks complete" }; +} + +function computeWarnings(project, docs) { + const warnings = []; + const tasks = docs.filter((d) => d.role === "task"); + const noStatus = tasks.filter((d) => !d.status); + if (noStatus.length) { + warnings.push({ sev: "Medium", note: `${noStatus.length} task(s) missing status field`, ws: "Tasks" }); + } + if (project.outline) { + const ws = project.outline.workstreams || []; + const emptyWs = ws.filter((w) => !w.tasks || !w.tasks.length); + if (emptyWs.length) { + warnings.push({ sev: "Low", note: `${emptyWs.length} workstream(s) have no linked tasks`, ws: "Workstreams" }); + } + if (project.outline.unassignedTasks?.length) { + warnings.push({ sev: "Low", note: `${project.outline.unassignedTasks.length} task(s) not assigned to a workstream`, ws: "Tasks" }); + } + } + const blocked = tasks.filter((d) => statusLabel(d.status) === "Blocked"); + if (blocked.length) { + warnings.push({ sev: "Medium", note: `${blocked.length} task(s) currently blocked`, ws: "Tasks" }); + } + return warnings; +} + +function getDashboardModel(project, docs) { + const tasks = docs.filter((d) => d.role === "task"); + const currentWork = tasks.filter((d) => statusLabel(d.status) === "In Progress"); + const blockers = tasks.filter((d) => statusLabel(d.status) === "Blocked"); + const progressDocs = docs + .filter((d) => d.role === "progress") + .sort((a, b) => (b.updated || "").localeCompare(a.updated || "")); + const health = computeHealth(docs); + const warnings = computeWarnings(project, docs); + const workstreams = project.outline?.workstreams || []; + const wsLookup = {}; + + workstreams.forEach((ws) => { + (ws.tasks || []).forEach((taskPath) => { + wsLookup[taskPath] = ws; + }); + }); + + return { tasks, currentWork, blockers, progressDocs, health, warnings, workstreams, wsLookup }; +} + +function getWorkspaceModel(index) { + const model = { + current: [], + blockers: [], + validation: [], + progress: [], + warnings: [], + counts: { projects: 0, current: 0, blockers: 0, validation: 0, progress: 0, warnings: 0 }, + }; + + if (!index) return model; + + for (const project of index.projects || []) { + if (!project.outline) continue; + const docs = (project.docs || []).map((p) => byPath(index.docs, p)).filter(Boolean); + const dashboard = getDashboardModel(project, docs); + const withProject = (item, extra = {}) => ({ ...item, project, ...extra }); + + dashboard.currentWork.forEach((task) => { + model.current.push(withProject(task, { workstream: dashboard.wsLookup[task.path] || null })); + }); + dashboard.blockers.forEach((task) => { + model.blockers.push(withProject(task, { workstream: dashboard.wsLookup[task.path] || null })); + }); + dashboard.tasks.forEach((task) => { + model.validation.push(withProject(task, { workstream: dashboard.wsLookup[task.path] || null })); + }); + dashboard.progressDocs.forEach((doc) => { + model.progress.push(withProject(doc)); + }); + dashboard.warnings.forEach((warning) => { + model.warnings.push({ ...warning, project }); + }); + } + + const byUpdatedDesc = (a, b) => (b.updated || b.project?.updated || "").localeCompare(a.updated || a.project?.updated || ""); + model.current.sort(byUpdatedDesc); + model.blockers.sort(byUpdatedDesc); + model.validation.sort(byUpdatedDesc); + model.progress.sort(byUpdatedDesc); + model.warnings.sort((a, b) => (b.project?.updated || "").localeCompare(a.project?.updated || "")); + + model.counts.current = model.current.length; + model.counts.blockers = model.blockers.length; + model.counts.validation = model.validation.length; + model.counts.progress = model.progress.length; + model.counts.warnings = model.warnings.length; + model.counts.projects = (index.projects || []).filter((p) => p.outline).length; + + return model; +} + +function getProjectStats(index, project) { + const docs = (project.docs || []).map((p) => byPath(index.docs, p)).filter(Boolean); + const dashboard = getDashboardModel(project, docs); + const relatedAssets = docs.filter((doc) => !["task", "workstream"].includes(doc.role)).length; + const openTasks = dashboard.tasks.filter((task) => statusLabel(task.status) !== "Complete"); + const latestDoc = docs + .slice() + .sort((a, b) => (b.updated || "").localeCompare(a.updated || ""))[0]; + + return { + project, + docs, + dashboard, + tasks: dashboard.tasks, + openTasks, + workstreams: dashboard.workstreams, + relatedAssets, + updated: latestDoc?.updated || project.created || "", + }; +} + +function formatShortDate(value) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); +} + +function pageCountFor(items, pageSize = WORKSPACE_PAGE_SIZE) { + return Math.max(1, Math.ceil((items?.length || 0) / pageSize)); +} + +function clampPage(page, totalPages) { + const parsed = Number(page); + const next = Number.isFinite(parsed) ? Math.floor(parsed) : 1; + return Math.min(Math.max(1, next), Math.max(1, totalPages)); +} + +function paginateItems(items, page, pageSize = WORKSPACE_PAGE_SIZE) { + const totalPages = pageCountFor(items, pageSize); + const safePage = clampPage(page, totalPages); + const start = (safePage - 1) * pageSize; + return { + visible: (items || []).slice(start, start + pageSize), + safePage, + totalPages, + }; +} + +const LinkButton = ({ children, title, className = "", ...props }) => ( + <button + {...props} + className={`link${className ? ` ${className}` : ""}`} + title={title || (typeof children === "string" ? children : undefined)} + type={props.type || "button"} + > + {children} + </button> +); + +const Pagination = ({ page, totalPages, onPageChange }) => { + if (totalPages <= 1) return null; + return ( + <div className="pagination" aria-label="Pagination"> + <button + className="btn" + type="button" + onClick={() => onPageChange(Math.max(1, page - 1))} + disabled={page === 1} + > + Previous + </button> + <span className="mono">Page {page} of {totalPages}</span> + <button + className="btn" + type="button" + onClick={() => onPageChange(Math.min(totalPages, page + 1))} + disabled={page === totalPages} + > + Next + </button> + </div> + ); +}; + +/* ================================================================ + Reusable components + ================================================================ */ +const CopyButton = ({ value, label = "value", className = "" }) => { + const [copied, setCopied] = useState(false); + const text = normalizeCopyValue(value); + + useEffect(() => { + if (!copied) return undefined; + const timeout = window.setTimeout(() => setCopied(false), 1200); + return () => window.clearTimeout(timeout); + }, [copied]); + + if (!text) return null; + + const stateLabel = copied ? `Copied ${label}` : `Copy ${label}`; + const handleClick = async (event) => { + event.preventDefault(); + event.stopPropagation(); + const ok = await copyTextToClipboard(text); + if (!ok) return; + setCopied(true); + announceCopy(label); + }; + + return ( + <button + className={`copy-btn${copied ? " is-copied" : ""}${className ? ` ${className}` : ""}`} + type="button" + onClick={handleClick} + aria-label={stateLabel} + title={stateLabel} + > + <Icon d={copied ? I.checkMark : I.copy} size={13} /> + </button> + ); +}; + +const Field = ({ label, children, mono, copyValue, copyLabel }) => ( + <div className="field"> + <div className="field-label">{label}</div> + <div className={"field-value" + (mono ? " mono" : "")}> + {children} + <CopyButton value={copyValue} label={copyLabel || label} /> + </div> + </div> +); + +const SectionHeader = ({ title, count, right, collapsible, open, onToggle }) => ( + <div + className={"section-head" + (collapsible ? " is-collapsible" : "")} + onClick={collapsible ? onToggle : undefined} + role={collapsible ? "button" : undefined} + > + <div className="section-title"> + <span>{title}</span> + {count != null && <span className="count">{count}</span>} + </div> + <div className="section-right"> + {right} + {collapsible && ( + <span className="caret"> + <Icon d={open ? I.chevU : I.chevD} size={16} /> + </span> + )} + </div> + </div> +); + +const Block = ({ title, children }) => ( + <section className="ws-block"> + <h3 className="ws-h">{title}</h3> + <div className="ws-body">{children}</div> + </section> +); + +/* ================================================================ + Navigation definitions + ================================================================ */ +const NAV = [ + { id: "overview", label: "Overview", icon: I.home }, + { id: "current", label: "Current Work", icon: I.list }, + { id: "blockers", label: "Blockers", icon: I.block }, + { id: "progress", label: "Progress", icon: I.trend }, + { id: "validation", label: "Validation", icon: I.check }, + { id: "warnings", label: "Warnings", icon: I.warn }, +]; + +const GLOBAL_NAV = [ + { id: "workspace-projects", label: "Projects", icon: I.grid, countKey: "projects" }, + { id: "workspace-current", label: "Open work", icon: I.list, countKey: "current" }, + { id: "workspace-progress", label: "Progress", icon: I.trend, countKey: "progress" }, + { id: "workspace-validation", label: "Validation", icon: I.check, countKey: "validation" }, + { id: "workspace-warnings", label: "Warnings", icon: I.warn, countKey: "warnings" }, + { id: "workspace-blockers", label: "Blockers", icon: I.block, countKey: "blockers" }, +]; + +const GLOBAL_ROUTES = new Set(GLOBAL_NAV.map((item) => item.id)); + +function readStoredNavigation() { + try { + const raw = window.localStorage.getItem(NAV_STATE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed?.version === NAV_STATE_VERSION ? parsed : null; + } catch (_) { + return null; + } +} + +function sanitizeWorkspacePages(value) { + const pages = {}; + if (!value || typeof value !== "object") return pages; + GLOBAL_NAV.forEach((item) => { + const page = Number(value[item.id]); + if (Number.isFinite(page) && page > 1) pages[item.id] = Math.floor(page); + }); + return pages; +} + +function findProjectForDoc(index, docPath) { + if (!docPath) return null; + return (index.projects || []).find((project) => (project.docs || []).includes(docPath)) || null; +} + +function projectHasWorkstream(project, wsPath) { + return !!project?.outline?.workstreams?.some((ws) => ws.path === wsPath); +} + +function fallbackRouteForProject(project) { + return project?.outline ? "overview" : "list"; +} + +function makeDefaultNavigation(index) { + const firstProject = (index.projects || []).find((p) => p.outline) || (index.projects || [])[0] || null; + return { + projectSlug: firstProject?.slug || null, + route: DEFAULT_WORKSPACE_ROUTE, + section: null, + docPath: null, + wsPath: null, + workspacePages: {}, + }; +} + +function restoreNavigation(index) { + const fallback = makeDefaultNavigation(index); + const stored = readStoredNavigation(); + if (!stored) return fallback; + + let project = (index.projects || []).find((p) => p.slug === stored.projectSlug) || null; + if (!project) project = (index.projects || []).find((p) => p.slug === fallback.projectSlug) || null; + if (!project) return fallback; + + const workspacePages = sanitizeWorkspacePages(stored.workspacePages); + if (GLOBAL_ROUTES.has(stored.route)) { + return { + ...fallback, + projectSlug: project.slug, + route: stored.route, + workspacePages, + }; + } + + if (stored.route === "document" && stored.docPath) { + const docProject = findProjectForDoc(index, stored.docPath); + if (docProject) { + return { + projectSlug: docProject.slug, + route: "document", + section: stored.docPath, + docPath: stored.docPath, + wsPath: projectHasWorkstream(docProject, stored.wsPath) ? stored.wsPath : null, + workspacePages, + }; + } + } + + if (stored.route === "workstream" && projectHasWorkstream(project, stored.wsPath)) { + return { + projectSlug: project.slug, + route: "workstream", + section: null, + docPath: null, + wsPath: stored.wsPath, + workspacePages, + }; + } + + const projectRoutes = project.outline ? new Set(["overview", "workstreams", "tasks"]) : new Set(["list"]); + if (projectRoutes.has(stored.route)) { + return { + projectSlug: project.slug, + route: stored.route, + section: stored.section || (stored.route === "overview" ? "overview" : null), + docPath: null, + wsPath: null, + workspacePages, + }; + } + + return { + ...fallback, + projectSlug: project.slug, + route: fallbackRouteForProject(project), + section: project.outline ? "overview" : null, + workspacePages, + }; +} + +function getTaskNavigation(index, project, taskDoc) { + if (!index || !project?.outline || !taskDoc || taskDoc.role !== "task") return null; + const workstreams = project.outline.workstreams || []; + let parent = workstreams.find((ws) => (ws.tasks || []).includes(taskDoc.path)) || null; + if (!parent && taskDoc.workstreamId) { + parent = workstreams.find((ws) => ws.id === taskDoc.workstreamId) || null; + } + + const tasks = (project.docs || []) + .map((path) => byPath(index.docs, path)) + .filter((doc) => doc?.role === "task"); + const tasksById = {}; + tasks.forEach((task) => { + if (task.taskId) tasksById[String(task.taskId).toUpperCase()] = task; + }); + + return { + parent, + siblings: parent ? (parent.tasks || []).map((path) => byPath(index.docs, path)).filter(Boolean) : [], + tasksById, + }; +} + +function listValue(value) { + if (Array.isArray(value)) return value.map(String).filter(Boolean); + if (value == null || value === "") return []; + return [String(value)]; +} + +/* ================================================================ + Sidebar + ================================================================ */ +function Sidebar({ index, projectSlug, route, section, onNavigate, onSelectProject }) { + const projects = index?.projects || []; + const current = projects.find((p) => p.slug === projectSlug); + const hasOutline = current?.outline; + const globalCounts = useMemo( + () => (index ? getWorkspaceModel(index).counts : {}), + [index] + ); + + const contractItems = useMemo(() => { + if (!hasOutline) return []; + const items = []; + if (current.outline.spec) items.push({ id: "spec", label: "Spec", icon: I.doc, path: current.outline.spec }); + if (current.outline.plan) items.push({ id: "plan", label: "Plan", icon: I.plan, path: current.outline.plan }); + (current.outline.decisions || []).forEach((p, i) => + items.push({ id: `dec-${i}`, label: "Decisions", icon: I.scale, path: p }) + ); + (current.outline.progress || []).forEach((p, i) => + items.push({ id: `prog-${i}`, label: i === 0 ? "Progress log" : `Progress ${i + 1}`, icon: I.clock, path: p }) + ); + return items; + }, [current, hasOutline]); + + return ( + <aside className="sidebar"> + <div className="brand" aria-label="Delano"> + <img className="brand-logo" src="/delano-logo.svg" alt="Delano" /> + </div> + + <div className="nav-section">Workspace</div> + <nav className="nav"> + {GLOBAL_NAV.map((it) => ( + <button + key={it.id} + className={"nav-item nav-item-count" + (route === it.id ? " is-active" : "")} + onClick={() => onNavigate(it.id)} + type="button" + > + <span className="nav-ico"> + <Icon d={it.icon} size={16} /> + </span> + <span className="nav-label">{it.label}</span> + <span className="nav-count mono">{globalCounts[it.countKey] || 0}</span> + </button> + ))} + </nav> + + <div className="nav-section">Selected project</div> + <div className="project-select-v3"> + <span className="project-select-mark"> + <Icon d={current?.outline ? I.grid : I.folder} size={15} /> + </span> + <select + className="project-select-control" + value={projectSlug || ""} + onChange={(e) => onSelectProject(e.target.value)} + aria-label="Project" + > + {projects.map((p) => ( + <option key={p.slug} value={p.slug}> + {p.title} + </option> + ))} + </select> + </div> + + {hasOutline && ( + <> + <nav className="nav"> + <button + className={"nav-item" + (route === "overview" ? " is-active" : "")} + onClick={() => onNavigate("overview")} + type="button" + > + <span className="nav-ico"> + <Icon d={I.home} size={16} /> + </span> + <span>Project overview</span> + </button> + </nav> + + + <div className="nav-section">Source contracts</div> + <nav className="nav source-nav-v2"> + {contractItems.filter((it) => !it.id.startsWith("prog-")).map((it) => ( + <button key={it.id} className={"nav-item" + (route === "document" && section === it.path ? " is-active" : "")} onClick={() => onNavigate("document", it.path)} type="button"> + <span className="nav-ico"><Icon d={it.icon} size={16} /></span> + <span>{it.label}</span> + </button> + ))} + <button className={"nav-item" + (route === "workstreams" ? " is-active" : "")} onClick={() => onNavigate("workstreams")} type="button"> + <span className="nav-ico"><Icon d={I.grid} size={16} /></span> + <span>Workstreams</span> + </button> + <button className={"nav-item" + (route === "tasks" ? " is-active" : "")} onClick={() => onNavigate("tasks")} type="button"> + <span className="nav-ico"><Icon d={I.task} size={16} /></span> + <span>Tasks</span> + </button> + <div className="nav-break">Progress</div> + {contractItems.filter((it) => it.id.startsWith("prog-")).map((it) => ( + <button key={it.id} className={"nav-item" + (route === "document" && section === it.path ? " is-active" : "")} onClick={() => onNavigate("document", it.path)} type="button"> + <span className="nav-ico"><Icon d={it.icon} size={16} /></span> + <span>{it.label}</span> + </button> + ))} + </nav> + </> + )} + + <div className="sidebar-foot"> + <button className="nav-item" type="button"> + <span className="nav-ico"> + <Icon d={I.gear} size={16} /> + </span> + <span>Viewer settings</span> + </button> + </div> + </aside> + ); +} + +/* ================================================================ + Topbar + ================================================================ */ +function Topbar({ project, index, docPath, onOpenAction }) { + const spec = project?.outline?.spec; + const specDoc = spec && index ? byPath(index.docs, spec) : null; + const title = project?.title || "Delano"; + const status = specDoc?.status || project?.status; + const updated = specDoc?.updated || ""; + const dateStr = updated + ? new Date(updated).toLocaleString("en-US", { + month: "short", day: "numeric", year: "numeric", + hour: "2-digit", minute: "2-digit", hour12: false, + }) + : ""; + + return ( + <header className="topbar"> + <div className="tb-project"> + <span className="tb-title">{title}</span> + {status && <StatusChip>{status}</StatusChip>} + </div> + <div className="tb-meta"> + {dateStr && ( + <> + <span>Last updated <strong>{dateStr}</strong></span> + <span className="tb-sep" /> + </> + )} + <span className="tb-readonly"> + <Icon d={I.lock} size={13} /> Read-only + </span> + </div> + <div className="tb-actions"> + {docPath && ( + <> + <button className="btn" onClick={() => onOpenAction("code", docPath)}> + <Icon d={I.code} size={14} /> Open in IDE + </button> + <button className="btn" onClick={() => onOpenAction("explorer", docPath)}> + <Icon d={I.folderOpen} size={14} /> Open folder + </button> + </> + )} + </div> + </header> + ); +} + +/* ================================================================ + Overview + ================================================================ */ +function Overview({ index, project, docs, scrollTarget, onOpenWorkstream, onOpenDoc, onOpenTasks }) { + const [open, setOpen] = useState({ current: true, blockers: true, validation: false, progress: false, warnings: false }); + const toggle = (k) => setOpen((o) => ({ ...o, [k]: !o[k] })); + + const dashboard = useMemo(() => getDashboardModel(project, docs), [project, docs]); + const { tasks, blockers, progressDocs, health, warnings, workstreams, wsLookup } = dashboard; + const taskByPath = {}; + tasks.forEach((task) => { + taskByPath[task.path] = task; + }); + const openTasks = tasks + .filter((task) => statusLabel(task.status) !== "Complete") + .sort((a, b) => { + const rank = { Blocked: 0, "In Progress": 1, Planned: 2, Complete: 3 }; + const statusDelta = (rank[statusLabel(a.status)] ?? 4) - (rank[statusLabel(b.status)] ?? 4); + if (statusDelta !== 0) return statusDelta; + return (b.updated || "").localeCompare(a.updated || ""); + }); + const workstreamRows = workstreams + .map((ws) => { + const wsTasks = (ws.tasks || []).map((path) => taskByPath[path]).filter(Boolean); + const openCount = wsTasks.filter((task) => statusLabel(task.status) !== "Complete").length; + return { ...ws, tasks: wsTasks, openCount }; + }) + .sort((a, b) => b.openCount - a.openCount || a.title.localeCompare(b.title)); + + const nextAction = blockers.length + ? "Resolve blocked tasks" + : health.pct < 100 + ? "Complete remaining tasks" + : "All tasks complete"; + + // Auto-open + scroll to section from sidebar nav + useEffect(() => { + if (!scrollTarget || scrollTarget === "overview") return; + const sectionKey = scrollTarget.replace("-nav", ""); + if (["blockers", "validation", "progress", "warnings", "current"].includes(sectionKey)) { + setOpen((o) => ({ ...o, [sectionKey]: true })); + setTimeout(() => { + const el = document.getElementById(`section-${sectionKey}`); + if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); + }, 100); + } + }, [scrollTarget]); + + return ( + <div className="page overview-v1"> + <div className="overview-v1-head"> + <h1 className="page-title">Overview</h1> + <div className="overview-signal-strip signal-color-filled"> + <button className={"signal-pill signal-pill-warning" + (warnings.length ? " has-count" : " is-zero")} onClick={() => toggle("warnings")} type="button"> + <Icon d={I.warn} size={14} /> + <span>Warnings</span> + <span className="mono">{warnings.length}</span> + </button> + <button className={"signal-pill signal-pill-blocker" + (blockers.length ? " has-count" : " is-zero")} onClick={() => toggle("blockers")} type="button"> + <Icon d={I.block} size={14} /> + <span>Blockers</span> + <span className="mono">{blockers.length}</span> + </button> + <button className={"signal-pill signal-pill-validation" + (tasks.length ? " has-count" : " is-zero")} onClick={() => toggle("validation")} type="button"> + <Icon d={I.check} size={14} /> + <span>Validation</span> + <span className="mono">{tasks.length}</span> + </button> + <button className={"signal-pill signal-pill-progress" + (progressDocs.length ? " has-count" : " is-zero")} onClick={() => toggle("progress")} type="button"> + <Icon d={I.trend} size={14} /> + <span>Progress</span> + <span className="mono">{progressDocs.length}</span> + </button> + </div> + </div> + + <section className="summary overview-summary"> + <Field label="Project" copyValue={project.slug} copyLabel="project ID"> + <span>{project.title}</span> + <span className="field-id mono">{project.slug}</span> + </Field> + <Field label="Status"><StatusChip>{project.status || "Planned"}</StatusChip></Field> + <Field label="Health"> + <span className="health"> + <span className="health-bar"><span className="health-fill" style={{ width: `${health.pct}%`, background: health.pct === 100 ? "var(--ok)" : undefined }} /></span> + <span className="health-label">{health.label}</span> + </span> + </Field> + <Field label="Next action">{nextAction}</Field> + </section> + + <section className="overview-delivery"> + <div className="delivery-panel"> + <SectionHeader title="Workstreams" count={workstreams.length} /> + {workstreamRows.length > 0 ? ( + <div className="delivery-list"> + {workstreamRows.map((ws) => ( + <button className="delivery-row" key={ws.path} type="button" onClick={() => onOpenWorkstream(ws.path)}> + <span className="delivery-row-main"> + <span className="delivery-title">{ws.title}</span> + <span className="delivery-meta">{ws.tasks.length} task{ws.tasks.length !== 1 ? "s" : ""}</span> + </span> + <span className="delivery-row-right"> + <span className="mono delivery-count">{ws.openCount} open</span> + <StatusChip>{ws.status || "Planned"}</StatusChip> + </span> + </button> + ))} + </div> + ) : ( + <div className="empty-state">No workstreams in this project.</div> + )} + </div> + + <div className="delivery-panel"> + <SectionHeader + title="Open tasks" + count={openTasks.length} + right={<button className="link-muted" type="button" onClick={onOpenTasks}>All tasks</button>} + /> + {openTasks.length > 0 ? ( + <div className="delivery-list"> + {openTasks.slice(0, 7).map((task) => { + const ws = wsLookup[task.path]; + return ( + <div className="delivery-task-row" key={task.path}> + <LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>{task.title}</LinkButton> + <span className="td-muted">{ws?.title || "Unassigned"}</span> + <StatusChip>{task.status || "Planned"}</StatusChip> + </div> + ); + })} + {openTasks.length > 7 && ( + <button className="delivery-more" type="button" onClick={onOpenTasks}> + {openTasks.length - 7} more open task{openTasks.length - 7 !== 1 ? "s" : ""} + </button> + )} + </div> + ) : ( + <div className="empty-state">No open tasks.</div> + )} + </div> + </section> + + <section className="overview-priority"> + <div className="overview-priority-main"> + <SectionHeader title="Warnings" count={warnings.length} collapsible open={open.warnings} onToggle={() => toggle("warnings")} /> + {warnings.length > 0 ? ( + <div className="preview-list"> + {warnings.slice(0, open.warnings ? warnings.length : 3).map((w, i) => ( + <div className="preview-row preview-row-warn" key={i}> + <span className={`chip ${w.sev === "Medium" ? "chip-warn" : "chip-low"}`}><span className="chip-dot" /> {w.sev}</span> + <span className="td-primary">{w.note}</span> + <span className="td-muted">{w.ws}</span> + </div> + ))} + </div> + ) : ( + <div className="empty-state">No warnings.</div> + )} + </div> + </section> + + <section className="block"> + <SectionHeader title="Progress" count={progressDocs.length} collapsible open={open.progress} onToggle={() => toggle("progress")} /> + {progressDocs.length > 0 ? ( + <div className="preview-list"> + {progressDocs.slice(0, open.progress ? progressDocs.length : 3).map((doc, i) => { + const progressMeta = formatShortDateTime(doc.updated); + const progressSnippet = stripRepeatedTitle(doc.title, doc.snippet); + return ( + <div className="preview-row preview-row-progress" key={i}> + <span className="mono preview-meta-time">{progressMeta}</span> + <LinkButton onClick={() => onOpenDoc(doc.path)} title={doc.title}>{doc.title}</LinkButton> + <span className="preview-copy">{progressSnippet}</span> + </div> + ); + })} + </div> + ) : ( + <div className="empty-state">No progress entries.</div> + )} + </section> + + <section className="block"> + <SectionHeader title="Validation" count={tasks.length} collapsible open={open.validation} onToggle={() => toggle("validation")} /> + <div className="preview-list preview-list-validation"> + {tasks.slice(0, open.validation ? tasks.length : 3).map((task, i) => { + const label = statusLabel(task.status); + const chipClass = label === "Complete" ? "chip-ok" : label === "Blocked" ? "chip-warn" : "chip-low"; + return ( + <div className="preview-row validation-row" key={i}> + <span className={`chip ${chipClass}`}><span className="chip-dot" /> {label}</span> + <LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>{task.title}</LinkButton> + <span className="td-muted">{(wsLookup[task.path]?.title) || "Unassigned"}</span> + </div> + ); + })} + </div> + </section> + + <div className="page-foot mono">viewer · read-only · generated from contracts at <span>{index.generatedAt || ""}</span></div> + </div> + ); +} + +/* ================================================================ + Workspace Pages + ================================================================ */ +function WorkspacePage({ index, view, page, onPageChange, onOpenProject, onOpenProjectDoc, onOpenProjectWorkstream }) { + const [projectFilter, setProjectFilter] = useState("all"); + const workspace = useMemo(() => getWorkspaceModel(index), [index]); + const projectStats = useMemo( + () => (index.projects || []).filter((p) => p.outline).map((project) => getProjectStats(index, project)), + [index] + ); + const projectFilterCounts = useMemo(() => { + const active = projectStats.filter((stat) => statusLabel(stat.project.status) !== "Complete").length; + return { + all: projectStats.length, + active, + complete: projectStats.length - active, + }; + }, [projectStats]); + const filteredProjectStats = useMemo(() => { + if (projectFilter === "active") { + return projectStats.filter((stat) => statusLabel(stat.project.status) !== "Complete"); + } + if (projectFilter === "complete") { + return projectStats.filter((stat) => statusLabel(stat.project.status) === "Complete"); + } + return projectStats; + }, [projectFilter, projectStats]); + const currentPage = page || 1; + + const titleMap = { + "workspace-projects": "Projects", + "workspace-current": "Open work", + "workspace-progress": "Progress", + "workspace-validation": "Validation", + "workspace-warnings": "Warnings", + "workspace-blockers": "Blockers", + }; + const title = titleMap[view] || "Workspace"; + const itemsForView = + view === "workspace-projects" ? filteredProjectStats : + view === "workspace-current" ? workspace.current : + view === "workspace-blockers" ? workspace.blockers : + view === "workspace-validation" ? workspace.validation : + view === "workspace-progress" ? workspace.progress : + view === "workspace-warnings" ? workspace.warnings : + []; + + useEffect(() => { + const totalPages = pageCountFor(itemsForView); + const safePage = clampPage(currentPage, totalPages); + if (safePage !== currentPage) onPageChange(safePage); + }, [view, itemsForView.length, currentPage, onPageChange]); + + const projectButton = (project) => ( + <LinkButton onClick={() => onOpenProject(project.slug)} title={project.title}> + {project.title} + </LinkButton> + ); + + const workstreamButton = (item) => + item.workstream ? ( + <LinkButton onClick={() => onOpenProjectWorkstream(item.project.slug, item.workstream.path)} title={item.workstream.title}> + {item.workstream.title} + </LinkButton> + ) : ( + <span className="td-muted">-</span> + ); + + const renderPagination = (pagination) => ( + <Pagination page={pagination.safePage} totalPages={pagination.totalPages} onPageChange={onPageChange} /> + ); + + const projectFilterControl = view === "workspace-projects" ? ( + <div className="project-filter" aria-label="Project status filter"> + {[ + ["all", "All"], + ["active", "Active"], + ["complete", "Complete"], + ].map(([value, label]) => ( + <button + className={"project-filter-option" + (projectFilter === value ? " is-active" : "")} + type="button" + onClick={() => setProjectFilter(value)} + aria-pressed={projectFilter === value} + key={value} + > + <span>{label}</span> + <span className="mono">{projectFilterCounts[value]}</span> + </button> + ))} + </div> + ) : null; + + const renderProjects = () => + filteredProjectStats.length > 0 ? ( + <div className="project-grid"> + {filteredProjectStats.map((stat) => ( + <button className="project-card" key={stat.project.slug} type="button" onClick={() => onOpenProject(stat.project.slug)}> + <span className="project-card-head"> + <span className="project-card-title">{stat.project.title}</span> + <StatusChip>{stat.project.status || "Planned"}</StatusChip> + </span> + <span className="project-card-dates"> + <span><span>Created</span> {formatShortDate(stat.project.created)}</span> + <span><span>Updated</span> {formatShortDate(stat.updated)}</span> + </span> + <span className="project-card-stats"> + <span><strong>{stat.workstreams.length}</strong> Workstreams</span> + <span><strong>{stat.openTasks.length}</strong> Open tasks</span> + <span><strong>{stat.tasks.length}</strong> Tasks</span> + <span><strong>{stat.relatedAssets}</strong> Assets</span> + </span> + </button> + ))} + </div> + ) : ( + <div className="empty-state">No projects match this filter.</div> + ); + + const renderTaskRows = (items, emptyText, kind) => { + const pagination = paginateItems(items, currentPage); + return ( + <> + {pagination.visible.length > 0 ? ( + <div className="table table-workspace"> + <div className="tr th"> + <div>Task</div> + <div>Project</div> + <div>Workstream</div> + <div>State</div> + </div> + {pagination.visible.map((task) => ( + <div className="tr" key={`${task.project.slug}:${task.path}`}> + <div className="td-primary"> + <LinkButton onClick={() => onOpenProjectDoc(task.project.slug, task.path)} title={task.title}> + {kind === "blocked" && <span className="dot dot-warn" />} {task.title} + </LinkButton> + </div> + <div>{projectButton(task.project)}</div> + <div>{workstreamButton(task)}</div> + <div> + <StatusChip>{task.status}</StatusChip> + </div> + </div> + ))} + </div> + ) : ( + <div className="empty-state">{emptyText}</div> + )} + {renderPagination(pagination)} + </> + ); + }; + + const renderProgress = () => { + const pagination = paginateItems(workspace.progress, currentPage); + + return ( + <> + {pagination.visible.length > 0 ? ( + <div className="timeline timeline-workspace"> + {pagination.visible.map((doc) => { + const date = doc.updated + ? new Date(doc.updated).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) + : ""; + return ( + <div className="tl-row" key={`${doc.project.slug}:${doc.path}`}> + <div className="tl-date mono">{date}</div> + <div className="tl-bullet"> + <span /> + </div> + <div className="tl-body"> + <div className="workspace-line"> + <LinkButton onClick={() => onOpenProjectDoc(doc.project.slug, doc.path)} title={doc.title}> + {doc.title} + </LinkButton> + <span className="td-muted-inline">{doc.project.title}</span> + </div> + <div className="td-muted small">{doc.snippet}</div> + </div> + </div> + ); + })} + </div> + ) : ( + <div className="empty-state">No progress entries across projects.</div> + )} + {renderPagination(pagination)} + </> + ); + }; + + const renderWarnings = () => { + const pagination = paginateItems(workspace.warnings, currentPage); + return ( + <> + {pagination.visible.length > 0 ? ( + <div className="table table-workspace-warnings"> + <div className="tr th"> + <div>Severity</div> + <div>Project</div> + <div>Note</div> + <div>Source</div> + </div> + {pagination.visible.map((w, i) => ( + <div className="tr" key={`${w.project.slug}:${w.note}:${i}`}> + <div> + <span className={`chip ${w.sev === "Medium" ? "chip-warn" : "chip-low"}`}> + <span className="chip-dot" /> {w.sev} + </span> + </div> + <div>{projectButton(w.project)}</div> + <div className="td-primary">{w.note}</div> + <div className="td-muted">{w.ws}</div> + </div> + ))} + </div> + ) : ( + <div className="empty-state" style={{ color: "var(--ok)" }}> + No warnings across projects. + </div> + )} + {renderPagination(pagination)} + </> + ); + }; + + const renderBody = () => { + if (view === "workspace-projects") return renderProjects(); + if (view === "workspace-current") return renderTaskRows(workspace.current, "No active tasks across projects."); + if (view === "workspace-blockers") return renderTaskRows(workspace.blockers, "No blockers across projects.", "blocked"); + if (view === "workspace-validation") return renderTaskRows(workspace.validation, "No tasks to validate across projects."); + if (view === "workspace-progress") return renderProgress(); + if (view === "workspace-warnings") return renderWarnings(); + return null; + }; + + const count = + view === "workspace-projects" ? filteredProjectStats.length : + view === "workspace-current" ? workspace.counts.current : + view === "workspace-blockers" ? workspace.counts.blockers : + view === "workspace-validation" ? workspace.counts.validation : + view === "workspace-progress" ? workspace.counts.progress : + view === "workspace-warnings" ? workspace.counts.warnings : + null; + + return ( + <div className="page"> + <section className="block"> + <SectionHeader title={title} count={count} right={projectFilterControl} /> + {renderBody()} + </section> + </div> + ); +} + +/* ================================================================ + Project Outline Pages + ================================================================ */ +function ProjectWorkstreamsPage({ index, project, docs, onOpenWorkstream, onOpenDoc }) { + const workstreams = project.outline?.workstreams || []; + const taskDocs = useMemo(() => { + const byTaskPath = {}; + docs.filter((doc) => doc.role === "task").forEach((task) => { + byTaskPath[task.path] = task; + }); + return byTaskPath; + }, [docs]); + + return ( + <div className="page"> + <h1 className="page-title">Workstreams</h1> + <section className="block"> + <SectionHeader title="Project outline" count={workstreams.length} /> + {workstreams.length > 0 ? ( + <div className="outline-list"> + {workstreams.map((ws) => { + const tasks = (ws.tasks || []).map((path) => taskDocs[path]).filter(Boolean); + return ( + <div className="outline-row" key={ws.path}> + <button className="outline-main" onClick={() => onOpenWorkstream(ws.path)} type="button"> + <span className="outline-title">{ws.title}</span> + <StatusChip>{ws.status || "Planned"}</StatusChip> + </button> + {tasks.length > 0 && ( + <div className="outline-sublist"> + {tasks.map((task) => ( + <button className="outline-subitem" key={task.path} onClick={() => onOpenDoc(task.path)} type="button"> + <span className="mono">{task.taskId || task.path.split("/").pop()?.replace(/\.md$/, "")}</span> + <span title={task.title}>{task.title}</span> + <StatusChip>{task.status || "Planned"}</StatusChip> + </button> + ))} + </div> + )} + </div> + ); + })} + </div> + ) : ( + <div className="empty-state">No workstreams in this project.</div> + )} + </section> + </div> + ); +} + +function ProjectTasksPage({ project, docs, onOpenDoc, onOpenWorkstream }) { + const dashboard = useMemo(() => getDashboardModel(project, docs), [project, docs]); + const { tasks, wsLookup } = dashboard; + + return ( + <div className="page"> + <h1 className="page-title">Tasks</h1> + <section className="block"> + <SectionHeader title="Project tasks" count={tasks.length} /> + {tasks.length > 0 ? ( + <div className="table table-4"> + <div className="tr th"> + <div>Task</div> + <div>Workstream</div> + <div>Status</div> + <div>Source</div> + </div> + {tasks.map((task) => { + const ws = wsLookup[task.path]; + return ( + <div className="tr" key={task.path}> + <div className="td-primary"> + <LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>{task.title}</LinkButton> + </div> + <div> + {ws ? ( + <LinkButton onClick={() => onOpenWorkstream(ws.path)} title={ws.title}>{ws.title}</LinkButton> + ) : ( + <span className="td-muted">Unassigned</span> + )} + </div> + <div><StatusChip>{task.status || "Planned"}</StatusChip></div> + <div className="mono td-muted">{task.path.split("/").pop()}</div> + </div> + ); + })} + </div> + ) : ( + <div className="empty-state">No tasks in this project.</div> + )} + </section> + </div> + ); +} + +/* ================================================================ + Dashboard Pages + ================================================================ */ +function DashboardPage({ project, docs, view, onOpenWorkstream, onOpenDoc }) { + const dashboard = useMemo(() => getDashboardModel(project, docs), [project, docs]); + const { tasks, currentWork, blockers, progressDocs, warnings, wsLookup } = dashboard; + const title = NAV.find((item) => item.id === view)?.label || "Dashboard"; + + const renderCurrentWork = () => + currentWork.length > 0 ? ( + <div className="table table-4"> + <div className="tr th"> + <div>Task</div> + <div>Workstream</div> + <div>Status</div> + <div>Source</div> + </div> + {currentWork.map((task, i) => { + const ws = wsLookup[task.path]; + return ( + <div className="tr" key={i}> + <div className="td-primary"> + <LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}> + {task.title} + </LinkButton> + </div> + <div> + {ws ? ( + <LinkButton onClick={() => onOpenWorkstream(ws.path)} title={ws.title}> + {ws.title} + </LinkButton> + ) : ( + <span className="td-muted">-</span> + )} + </div> + <div> + <StatusChip>{task.status}</StatusChip> + </div> + <div className="mono td-muted">{task.path.split("/").pop()}</div> + </div> + ); + })} + </div> + ) : ( + <div className="empty-state">No active tasks.</div> + ); + + const renderBlockers = () => + blockers.length > 0 ? ( + <div className="table table-3"> + <div className="tr th"> + <div>Task</div> + <div>Workstream</div> + <div>Details</div> + </div> + {blockers.map((task, i) => { + const ws = wsLookup[task.path]; + return ( + <div className="tr" key={i}> + <div className="td-primary"> + <LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}> + <span className="dot dot-warn" /> {task.title} + </LinkButton> + </div> + <div> + {ws ? ( + <LinkButton onClick={() => onOpenWorkstream(ws.path)} title={ws.title}> + {ws.title} + </LinkButton> + ) : ( + <span className="td-muted">-</span> + )} + </div> + <div className="td-muted">{task.snippet || "-"}</div> + </div> + ); + })} + </div> + ) : ( + <div className="empty-state">No blockers.</div> + ); + + const renderValidation = () => + tasks.length > 0 ? ( + <div className="table table-3"> + <div className="tr th"> + <div>Task</div> + <div>Workstream</div> + <div>State</div> + </div> + {tasks.map((task, i) => { + const ws = wsLookup[task.path]; + const label = statusLabel(task.status); + const chipClass = label === "Complete" ? "chip-ok" : label === "Blocked" ? "chip-warn" : "chip-low"; + return ( + <div className="tr" key={i}> + <div className="td-primary"> + <LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}> + {task.title} + </LinkButton> + </div> + <div> + {ws ? ( + <LinkButton onClick={() => onOpenWorkstream(ws.path)} title={ws.title}> + {ws.title} + </LinkButton> + ) : ( + <span className="td-muted">-</span> + )} + </div> + <div> + <span className={`chip ${chipClass}`}> + <span className="chip-dot" /> {label} + </span> + </div> + </div> + ); + })} + </div> + ) : ( + <div className="empty-state">No tasks to validate.</div> + ); + + const renderProgress = () => + progressDocs.length > 0 ? ( + <div className="timeline"> + {progressDocs.map((doc, i) => { + const date = doc.updated + ? new Date(doc.updated).toLocaleDateString("en-US", { month: "short", day: "numeric" }) + : ""; + return ( + <div className="tl-row" key={i}> + <div className="tl-date mono">{date}</div> + <div className="tl-bullet"> + <span /> + </div> + <div className="tl-body"> + <div> + <LinkButton onClick={() => onOpenDoc(doc.path)} title={doc.title}> + {doc.title} + </LinkButton> + </div> + <div className="td-muted small">{doc.snippet}</div> + </div> + </div> + ); + })} + </div> + ) : ( + <div className="empty-state">No progress entries.</div> + ); + + const renderWarnings = () => + warnings.length > 0 ? ( + <div className="table table-3"> + <div className="tr th"> + <div>Severity</div> + <div>Note</div> + <div>Source</div> + </div> + {warnings.map((w, i) => ( + <div className="tr" key={i}> + <div> + <span className={`chip ${w.sev === "Medium" ? "chip-warn" : "chip-low"}`}> + <span className="chip-dot" /> {w.sev} + </span> + </div> + <div className="td-primary">{w.note}</div> + <div className="td-muted">{w.ws}</div> + </div> + ))} + </div> + ) : ( + <div className="empty-state" style={{ color: "var(--ok)" }}> + No warnings. + </div> + ); + + const renderBody = () => { + if (view === "current") return renderCurrentWork(); + if (view === "blockers") return renderBlockers(); + if (view === "validation") return renderValidation(); + if (view === "progress") return renderProgress(); + if (view === "warnings") return renderWarnings(); + return null; + }; + + const count = + view === "current" ? currentWork.length : + view === "blockers" ? blockers.length : + view === "validation" ? tasks.length : + view === "progress" ? progressDocs.length : + view === "warnings" ? warnings.length : + null; + + return ( + <div className="page"> + <h1 className="page-title">{title}</h1> + <section className="block"> + <SectionHeader title={title} count={count} /> + {renderBody()} + </section> + </div> + ); +} + +/* ================================================================ + Workstream Detail + ================================================================ */ +function WorkstreamDetail({ index, project, wsPath, onBack, onOpenDoc }) { + const [wsDoc, setWsDoc] = useState(null); + + useEffect(() => { + if (!wsPath) return; + fetch(`/api/doc?path=${encodeURIComponent(wsPath)}`) + .then((r) => r.json()) + .then(setWsDoc); + }, [wsPath]); + + const outline = project.outline; + const wsOutline = outline?.workstreams?.find((w) => w.path === wsPath); + const tasks = useMemo( + () => (wsOutline?.tasks || []).map((p) => byPath(index.docs, p)).filter(Boolean), + [wsOutline, index.docs] + ); + + if (!wsDoc) { + return ( + <div className="page"> + <div className="empty-state">Loading workstream...</div> + </div> + ); + } + + const title = wsDoc.title || wsOutline?.title || "Workstream"; + const status = wsDoc.status || wsOutline?.status; + const owner = wsDoc.frontmatter?.owner || ""; + const created = wsDoc.frontmatter?.created || ""; + const updated = wsDoc.updated || ""; + + // Parse markdown into named sections + const body = (wsDoc.markdown || "").replace(/^---[\s\S]*?\n---\r?\n/, ""); + const sections = {}; + let currentSection = "__body"; + sections[currentSection] = []; + for (const line of body.split(/\r?\n/)) { + const heading = line.match(/^#{1,3}\s+(.+?)\s*$/); + if (heading) { + currentSection = heading[1].toLowerCase().replace(/[^a-z0-9]+/g, "-"); + sections[currentSection] = []; + } else { + if (!sections[currentSection]) sections[currentSection] = []; + sections[currentSection].push(line); + } + } + const sectionHtml = (key) => { + const lines = sections[key]; + if (!lines || !lines.filter((l) => l.trim()).length) return null; + return parseBlocks(lines); + }; + + const summaryHtml = sectionHtml("summary") || sectionHtml("__body"); + const goalHtml = sectionHtml("goal") || sectionHtml("objective"); + const scopeHtml = sectionHtml("scope"); + const notesHtml = sectionHtml("notes"); + const decisionsHtml = sectionHtml("decisions"); + + const fmtDate = (d) => + d ? new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "—"; + + return ( + <div className="page"> + <button className="back" onClick={onBack}> + <Icon d={I.arrowL} size={14} /> Back to Overview + </button> + <div className="ws-eyebrow">Workstream</div> + <h1 className="page-title">{title}</h1> + + <section className="summary summary-tight"> + <Field label="Status"> + <StatusChip>{status || "Planned"}</StatusChip> + </Field> + <Field label="Owner">{owner || "—"}</Field> + <Field label="Created">{fmtDate(created)}</Field> + <Field label="Last updated">{fmtDate(updated)}</Field> + </section> + + <div className="two-col"> + <div className="col-main"> + {summaryHtml && ( + <Block title="Summary"> + <div dangerouslySetInnerHTML={{ __html: summaryHtml }} /> + </Block> + )} + {goalHtml && ( + <Block title="Goal"> + <div dangerouslySetInnerHTML={{ __html: goalHtml }} /> + </Block> + )} + {scopeHtml && ( + <Block title="Scope"> + <div dangerouslySetInnerHTML={{ __html: scopeHtml }} /> + </Block> + )} + + <Block title="Tasks"> + <ul className="checklist"> + {tasks.map((t, i) => { + const done = statusLabel(t.status) === "Complete"; + const taskId = t.taskId || t.path.split("/").pop()?.replace(/\.md$/, ""); + return ( + <li key={i} className={done ? "done" : ""}> + <span className="cb"> + {done && <Icon d={<path d="M5 12.5l4 4 10-11" />} size={11} />} + </span> + <LinkButton onClick={() => onOpenDoc(t.path, wsPath)} title={t.title}> + {t.title} + </LinkButton> + <span className="checklist-meta"> + <StatusChip>{t.status || "Planned"}</StatusChip> + <span className="task-id-copy mono"> + <span>{taskId}</span> + <CopyButton value={taskId} label="task ID" /> + </span> + </span> + </li> + ); + })} + {!tasks.length && ( + <li style={{ color: "var(--ink-50)", listStyle: "none" }}> + No tasks linked to this workstream. + </li> + )} + </ul> + </Block> + + {notesHtml && ( + <Block title="Notes"> + <div dangerouslySetInnerHTML={{ __html: notesHtml }} /> + </Block> + )} + {decisionsHtml && ( + <Block title="Decisions"> + <div dangerouslySetInnerHTML={{ __html: decisionsHtml }} /> + </Block> + )} + </div> + + <aside className="col-side"> + <div className="side-block"> + <div className="side-head">Details</div> + <dl className="dl"> + <dt>Status</dt> + <dd> + <StatusChip>{status || "Planned"}</StatusChip> + </dd> + {owner && ( + <> + <dt>Owner</dt> + <dd>{owner}</dd> + </> + )} + <dt> + <span>Source path</span> + <CopyButton value={wsPath} label="source path" /> + </dt> + <dd className="mono small"> + <span className="copy-value">{wsPath}</span> + </dd> + {wsOutline?.id && ( + <> + <dt> + <span>ID</span> + <CopyButton value={wsOutline.id} label="workstream ID" /> + </dt> + <dd className="mono"> + <span>{wsOutline.id}</span> + </dd> + </> + )} + </dl> + </div> + + <div className="side-block"> + <div className="side-head">Task summary</div> + <dl className="dl"> + <dt>Total</dt> + <dd className="mono">{tasks.length}</dd> + <dt>Open</dt> + <dd className="mono">{tasks.filter((task) => statusLabel(task.status) !== "Complete").length}</dd> + <dt>Complete</dt> + <dd className="mono">{tasks.filter((task) => statusLabel(task.status) === "Complete").length}</dd> + </dl> + </div> + </aside> + </div> + </div> + ); +} + +/* ================================================================ + Document Reader + ================================================================ */ +function DocumentReader({ doc, project, index, onBack, onOpenAction, onOpenDoc, onOpenWorkstream, onOpenTasks }) { + if (!doc) { + return ( + <div className="page"> + <div className="empty-state">Loading document...</div> + </div> + ); + } + + const props = Object.entries(doc.frontmatter || {}); + const taskNav = getTaskNavigation(index, project, doc); + const taskReference = (value) => { + if (!taskNav) return null; + return taskNav.tasksById[String(value || "").trim().toUpperCase()] || null; + }; + const renderMetaValue = (key, value) => { + const values = listValue(value); + + if (doc.role === "task" && key === "workstream" && taskNav?.parent) { + return ( + <LinkButton onClick={() => onOpenWorkstream(taskNav.parent.path)} title={taskNav.parent.title}> + {values.join(", ") || taskNav.parent.id || taskNav.parent.title} + </LinkButton> + ); + } + + if (doc.role === "task" && ["depends_on", "conflicts_with"].includes(key) && values.length) { + return values.map((item, index) => { + const relatedTask = taskReference(item); + return ( + <React.Fragment key={`${key}:${item}`}> + {index > 0 && <span className="meta-separator">,</span>} + {relatedTask ? ( + <LinkButton onClick={() => onOpenDoc(relatedTask.path, taskNav?.parent?.path || null)} title={relatedTask.title}> + {item} + </LinkButton> + ) : ( + <span>{item}</span> + )} + </React.Fragment> + ); + }); + } + + if (Array.isArray(value)) return value.join(", "); + return String(value ?? ""); + }; + const fmtDate = (d) => + d + ? new Date(d).toLocaleString("en-US", { + month: "short", day: "numeric", year: "numeric", + hour: "2-digit", minute: "2-digit", hour12: false, + }) + : "—"; + + return ( + <div className="page doc-reader-page"> + <div className="doc-reader-main"> + {onBack && ( + <button className="back" onClick={onBack}> + <Icon d={I.arrowL} size={14} /> Back + </button> + )} + <div className="ws-eyebrow">{titleCase(doc.role)}</div> + <h1 className="page-title">{doc.title}</h1> + + <article + className="md-body" + dangerouslySetInnerHTML={{ __html: renderMarkdown(doc.markdown || "") }} + /> + </div> + + <aside className="doc-side" aria-label="Document context"> + {doc.role === "task" && taskNav && ( + <div className="side-block task-nav-block"> + <div className="side-head">Task navigation</div> + <div className="task-parent"> + <div className="side-label">Parent workstream</div> + {taskNav.parent ? ( + <button className="task-parent-link" type="button" onClick={() => onOpenWorkstream(taskNav.parent.path)}> + <span>{taskNav.parent.title}</span> + <Icon d={I.chevR} size={13} /> + </button> + ) : ( + <div className="empty-state">No parent workstream found.</div> + )} + </div> + <div className="task-nav-actions"> + <button className="link-muted" type="button" onClick={onOpenTasks}>All project tasks</button> + </div> + <div className="side-label side-label-list"> + Sibling tasks <span className="count">{taskNav.siblings.length}</span> + </div> + <ul className="side-list task-sibling-list"> + {taskNav.siblings.map((task) => { + const isCurrent = task.path === doc.path; + return ( + <li key={task.path} className={isCurrent ? "is-current" : ""}> + <button + className="side-list-button" + type="button" + onClick={() => onOpenDoc(task.path, taskNav.parent?.path || null)} + disabled={isCurrent} + > + <span className="sl-name">{task.title}</span> + <span className="sl-right"> + <StatusChip>{task.status || "Planned"}</StatusChip> + {!isCurrent && <Icon d={I.chevR} size={13} />} + </span> + </button> + </li> + ); + })} + {!taskNav.siblings.length && ( + <li className="side-list-empty">No siblings</li> + )} + </ul> + </div> + )} + + <div className="doc-meta-panel" aria-label="Document metadata"> + <div className="doc-meta-title">Metadata</div> + <dl className="dl doc-meta-list"> + <dt> + <span>Path</span> + <CopyButton value={doc.path} label="source path" /> + </dt> + <dd className="mono"> + <span className="copy-value">{doc.path}</span> + </dd> + {doc.status && ( + <> + <dt>Status</dt> + <dd><StatusChip>{doc.status}</StatusChip></dd> + </> + )} + <dt>Updated</dt> + <dd>{fmtDate(doc.updated)}</dd> + {props.map(([k, v]) => { + const copyable = isCopyableMetaKey(k); + return ( + <React.Fragment key={k}> + <dt> + <span>{k}</span> + {copyable && ( + <CopyButton value={v} label={copyLabelFromMetaKey(k, doc.role)} /> + )} + </dt> + <dd> + {renderMetaValue(k, v)} + </dd> + </React.Fragment> + ); + })} + </dl> + </div> + </aside> + </div> + ); +} + +/* ================================================================ + Document List (for non-project folders like context, templates) + ================================================================ */ +function DocumentList({ index, project, docs, onOpenDoc }) { + const [query, setQuery] = useState(""); + const filtered = useMemo(() => { + if (!query) return docs; + const q = query.toLowerCase(); + return docs.filter((d) => { + const haystack = [d.title, d.path, d.snippet, d.role].join(" ").toLowerCase(); + return haystack.includes(q); + }); + }, [docs, query]); + + return ( + <div className="page"> + <h1 className="page-title">{project.title}</h1> + + <div style={{ maxWidth: "400px" }}> + <input + className="search-input" + placeholder="Search documents..." + value={query} + onChange={(e) => setQuery(e.target.value)} + /> + </div> + + <div className="table table-list"> + <div className="tr th"> + <div>Document</div> + <div>Role</div> + <div>Status</div> + <div>Updated</div> + </div> + {filtered.map((doc, i) => ( + <div className="tr" key={i} style={{ cursor: "pointer" }} onClick={() => onOpenDoc(doc.path)}> + <div> + <div className="td-primary">{doc.title}</div> + <div className="mono td-muted" style={{ fontSize: "11px", marginTop: "2px" }}> + {doc.path} + </div> + </div> + <div className="td-muted">{titleCase(doc.role)}</div> + <div> + {doc.status ? <StatusChip>{doc.status}</StatusChip> : <span className="td-muted">—</span>} + </div> + <div className="td-muted"> + {doc.updated + ? new Date(doc.updated).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : "—"} + </div> + </div> + ))} + {!filtered.length && ( + <div style={{ padding: "32px 0", color: "var(--ink-50)", textAlign: "center" }}> + {query ? "No documents match your search." : "No documents in this folder."} + </div> + )} + </div> + + <div className="page-foot mono"> + viewer · read-only · {docs.length} document{docs.length !== 1 ? "s" : ""} · generated at{" "} + <span>{index.generatedAt || ""}</span> + </div> + </div> + ); +} + +/* ================================================================ + App — root component + ================================================================ */ +function App() { + const [index, setIndex] = useState(null); + const [projectSlug, setProjectSlug] = useState(null); + const [route, setRoute] = useState(DEFAULT_WORKSPACE_ROUTE); + const [section, setSection] = useState(null); + const [docPath, setDocPath] = useState(null); + const [doc, setDoc] = useState(null); + const [wsPath, setWsPath] = useState(null); + const [workspacePages, setWorkspacePages] = useState({}); + + // Load index on mount + useEffect(() => { + fetch("/api/index") + .then((r) => r.json()) + .then((data) => { + const nav = restoreNavigation(data); + setIndex(data); + setProjectSlug(nav.projectSlug); + setRoute(nav.route); + setSection(nav.section); + setDocPath(nav.docPath); + setWsPath(nav.wsPath); + setWorkspacePages(nav.workspacePages); + }); + }, []); + + useEffect(() => { + if (!index || !projectSlug) return; + try { + window.localStorage.setItem(NAV_STATE_KEY, JSON.stringify({ + version: NAV_STATE_VERSION, + projectSlug, + route, + section, + docPath, + wsPath, + workspacePages, + })); + } catch (_) { + /* ignore unavailable storage */ + } + }, [index, projectSlug, route, section, docPath, wsPath, workspacePages]); + + // Load doc when docPath changes + useEffect(() => { + if (!docPath) { + setDoc(null); + return; + } + setDoc(null); + fetch(`/api/doc?path=${encodeURIComponent(docPath)}`) + .then((r) => r.json()) + .then(setDoc); + }, [docPath]); + + // Scroll to top on route change + useEffect(() => { + const main = document.querySelector(".main"); + if (main) main.scrollTo(0, 0); + }, [route, wsPath, docPath]); + + const handleWorkspacePageChange = useCallback((view, nextPage) => { + const page = Math.max(1, Math.floor(Number(nextPage) || 1)); + setWorkspacePages((pages) => ({ ...pages, [view]: page })); + }, []); + + const updateCurrentWorkspacePage = useCallback( + (nextPage) => handleWorkspacePageChange(route, nextPage), + [handleWorkspacePageChange, route] + ); + + if (!index) { + return ( + <div style={{ padding: "48px", color: "var(--ink-50)", fontFamily: "var(--font-sans)" }}> + Loading Delano viewer... + </div> + ); + } + + const { project, docs } = getProjectData(index, projectSlug); + const hasOutline = project?.outline; + + const handleSelectProject = (slug) => { + setProjectSlug(slug); + const p = index.projects.find((pp) => pp.slug === slug); + const nextRoute = fallbackRouteForProject(p); + setRoute(nextRoute); + setSection(nextRoute === "overview" ? "overview" : null); + setDocPath(null); + setDoc(null); + setWsPath(null); + }; + + const handleNavigate = (newRoute, newSection) => { + if (newRoute === "document" && newSection) { + setRoute("document"); + setDocPath(newSection); + setSection(newSection); + } else { + setRoute(newRoute); + setSection(newSection || null); + setDocPath(null); + setDoc(null); + } + setWsPath(null); + }; + + const handleOpenWorkstream = (path) => { + setRoute("workstream"); + setWsPath(path); + setSection(null); + setDocPath(null); + setDoc(null); + }; + + const handleOpenDoc = (path, contextWsPath) => { + setRoute("document"); + setDocPath(path); + setSection(path); + if (contextWsPath !== undefined) setWsPath(contextWsPath); + }; + + const handleOpenProject = (slug) => { + const p = index.projects.find((pp) => pp.slug === slug); + setProjectSlug(slug); + const nextRoute = fallbackRouteForProject(p); + setRoute(nextRoute); + setSection(nextRoute === "overview" ? "overview" : null); + setWsPath(null); + setDocPath(null); + setDoc(null); + }; + + const handleOpenProjectDoc = (slug, path) => { + setProjectSlug(slug); + setRoute("document"); + setDocPath(path); + setSection(path); + setWsPath(null); + }; + + const handleOpenProjectWorkstream = (slug, path) => { + setProjectSlug(slug); + setRoute("workstream"); + setWsPath(path); + setSection(null); + setDocPath(null); + setDoc(null); + }; + + const handleOpenAction = async (target, path) => { + try { + await fetch(`/api/open?target=${encodeURIComponent(target)}&path=${encodeURIComponent(path)}`, { + method: "POST", + }); + } catch (_) { + /* ignore */ + } + }; + + const handleBack = () => { + if (route === "document" && wsPath) { + setRoute("workstream"); + setDocPath(null); + setDoc(null); + setSection(null); + } else { + setRoute(hasOutline ? "overview" : "list"); + setSection(hasOutline ? "overview" : null); + setWsPath(null); + setDocPath(null); + setDoc(null); + } + }; + + let mainContent; + if (route === "workstream" && wsPath && hasOutline) { + mainContent = ( + <WorkstreamDetail + index={index} + project={project} + wsPath={wsPath} + onBack={handleBack} + onOpenDoc={handleOpenDoc} + /> + ); + } else if (route === "document" && docPath) { + mainContent = ( + <DocumentReader + doc={doc} + project={project} + index={index} + onBack={handleBack} + onOpenAction={handleOpenAction} + onOpenDoc={handleOpenDoc} + onOpenWorkstream={handleOpenWorkstream} + onOpenTasks={() => handleNavigate("tasks")} + /> + ); + } else if (GLOBAL_ROUTES.has(route)) { + mainContent = ( + <WorkspacePage + index={index} + view={route} + page={workspacePages[route] || 1} + onPageChange={updateCurrentWorkspacePage} + onOpenProject={handleOpenProject} + onOpenProjectDoc={handleOpenProjectDoc} + onOpenProjectWorkstream={handleOpenProjectWorkstream} + /> + ); + } else if (route === "workstreams" && hasOutline) { + mainContent = ( + <ProjectWorkstreamsPage + index={index} + project={project} + docs={docs} + onOpenWorkstream={handleOpenWorkstream} + onOpenDoc={handleOpenDoc} + /> + ); + } else if (route === "tasks" && hasOutline) { + mainContent = ( + <ProjectTasksPage + project={project} + docs={docs} + onOpenDoc={handleOpenDoc} + onOpenWorkstream={handleOpenWorkstream} + /> + ); + } else if (route === "overview" && hasOutline) { + mainContent = ( + <Overview + index={index} + project={project} + docs={docs} + scrollTarget={section} + onOpenWorkstream={handleOpenWorkstream} + onOpenDoc={handleOpenDoc} + onOpenTasks={() => handleNavigate("tasks")} + /> + ); + } else { + mainContent = ( + <DocumentList index={index} project={project} docs={docs} onOpenDoc={handleOpenDoc} /> + ); + } + + return ( + <div className="app"> + <Sidebar + index={index} + projectSlug={projectSlug} + route={route} + section={section} + onNavigate={handleNavigate} + onSelectProject={handleSelectProject} + /> + <div className="main"> + <Topbar + project={project} + index={index} + docPath={docPath || (hasOutline ? project.outline.spec : null)} + onOpenAction={handleOpenAction} + /> + + <div className="content content-reader-head-c">{mainContent}</div> + + </div> + <div id="copy-live" className="sr-only" aria-live="polite" aria-atomic="true"></div> + </div> + ); +} + +ReactDOM.createRoot(document.getElementById("root")).render(<App />); diff --git a/.delano/viewer/public/delano-logo.svg b/.delano/viewer/public/delano-logo.svg new file mode 100644 index 0000000..50f5e84 --- /dev/null +++ b/.delano/viewer/public/delano-logo.svg @@ -0,0 +1,60 @@ +<svg width="475" height="162" viewBox="0 0 475 162" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_1_45)"> +<g filter="url(#filter0_n_1_45)"> +<path d="M55.4088 10C56.0393 10.5081 57.647 12.5621 58.1966 13.2506C61.799 17.7979 65.2999 22.4231 68.6969 27.1231C70.7226 29.9079 72.9327 32.5646 74.269 35.7216C74.79 36.9536 75.4008 38.2079 75.9611 39.4275C78.0623 43.9784 80.1253 48.5467 82.1479 53.1322C83.2807 55.6447 84.3862 58.1686 85.4656 60.7037C85.8868 61.6907 86.7304 63.5737 86.9551 64.5212C87.0912 65.0943 87.1839 67.301 87.2222 67.9856C87.3643 70.6104 87.4903 73.2361 87.6001 75.8624C87.722 78.3932 87.9055 81.4542 87.8097 83.9502C87.7996 84.2051 85.5502 88.8072 85.2792 89.381C83.9348 92.2388 82.6155 95.1087 81.3205 97.9905C80.5899 99.6049 79.8371 101.245 79.1397 102.874C78.8232 103.608 78.5713 104.81 78.3606 105.624L77.1644 110.213C76.4196 113.057 75.7021 115.91 75.0117 118.769C74.7941 119.659 74.5784 120.55 74.3557 121.439C73.7591 123.83 73.5031 126.309 73.1745 128.751C72.6757 132.451 72.2091 136.154 71.7747 139.861C71.5742 142.894 71.1267 146.04 70.8607 149.073C70.7921 149.851 71.0834 152.216 69.5374 151.449C69.2048 151.283 69.1071 146.362 68.9337 145.749C67.9929 136.648 67.0296 127.724 64.7977 118.831C64.1585 116.207 63.4997 113.588 62.8213 110.974C62.0989 108.205 61.3226 104.65 60.3126 102.004C58.8459 98.1605 56.9631 94.333 55.4134 90.5135C54.6413 88.6113 53.6075 86.767 52.9298 84.8248C52.672 84.0421 52.3331 80.4915 52.2137 79.437C51.9446 77.1043 51.7052 74.768 51.4958 72.429C51.3649 70.9958 51.163 69.4066 51.2102 67.982C51.3258 64.4971 51.5463 61.006 51.7727 57.5274C52.1985 50.9002 52.6659 44.2756 53.1752 37.6541C53.611 31.4317 54.0791 25.2116 54.5796 18.9941C54.7996 16.325 54.9958 12.5205 55.4088 10Z" fill="#232C2B"/> +<path d="M69.5374 151.449C68.9878 150.761 79.9164 66.7861 55.4088 10C54.9958 12.5205 54.7996 16.325 54.5796 18.9941C54.0791 25.2116 53.611 31.4317 53.1752 37.6541C52.6659 44.2756 52.1985 50.9002 51.7727 57.5274C51.5463 61.006 51.3258 64.4971 51.2102 67.982C51.163 69.4066 51.3649 70.9958 51.4958 72.429C51.7052 74.768 51.9446 77.1043 52.2137 79.437C52.3331 80.4915 52.672 84.0421 52.9298 84.8248C53.6075 86.767 54.6413 88.6113 55.4134 90.5135C56.9631 94.333 58.8459 98.1605 60.3126 102.004C61.3226 104.65 62.0989 108.205 62.8213 110.974C63.4997 113.588 64.1585 116.207 64.7977 118.831C67.0296 127.724 67.9929 136.648 68.9337 145.749C69.1071 146.362 69.2048 151.283 69.5374 151.449Z" fill="#32453E"/> +<path d="M71.111 89.3149C71.1548 89.2979 71.2755 41.2372 55.4089 10C54.9959 12.5205 54.7997 16.325 54.5797 18.9941C54.0792 25.2116 53.611 31.4317 53.1753 37.6541C52.666 44.2756 51.7728 57.5275 51.7728 57.5275L61.9762 71.602C61.9762 71.602 71.1686 89.2465 71.111 89.3149Z" fill="#44514D"/> +<path d="M124.343 46.5553L124.475 46.4929C124.568 46.8631 122.76 58.509 122.535 60.0715C121.681 66.0692 120.782 72.0606 119.838 78.0453C119.367 81.0735 118.899 84.1842 118.352 87.202C118.208 87.9967 117.55 89.8389 117.269 90.7016L115.342 96.6042C114.194 100.214 112.988 103.805 111.724 107.377C111.264 108.678 110.666 110.746 110.077 111.92C107.873 116.315 105.641 120.712 103.369 125.073C101.911 127.655 100.322 130.21 98.9993 132.864C96.4979 137.881 94.1558 142.985 91.8792 148.107C91.5718 148.798 91.083 150.024 90.6477 150.608C87.9166 150.927 85.1764 151.16 82.4301 151.306C80.7159 151.413 78.7184 151.561 77.0233 151.535C75.4552 151.567 73.2703 151.646 71.7354 151.519C72.4711 144.787 73.3821 138.075 74.4665 131.389C74.7749 129.453 75.0501 127.495 75.3967 125.551C76.2181 120.934 77.6119 116.329 78.796 111.787C79.3433 109.687 79.8784 107.51 80.479 105.449C81.2439 102.828 82.669 99.9769 83.7997 97.4629C87.849 88.4005 92.5282 79.629 97.804 71.2112C98.7443 69.6919 100.448 66.8413 101.496 65.4868C102.216 64.5543 104.978 62.1097 105.999 61.1841C108.189 59.219 110.408 57.2843 112.654 55.3808C113.637 54.5317 115.208 52.9617 116.196 52.3054C118.692 50.6498 121.743 47.8864 124.343 46.5553Z" fill="#25332D"/> +<path d="M71.7354 151.519C72.4711 144.787 73.3821 138.075 74.4665 131.389C74.7749 129.453 75.0501 127.495 75.3967 125.551C76.2181 120.934 77.6119 116.329 78.796 111.787C79.3433 109.687 79.8784 107.51 80.479 105.449C81.2439 102.828 82.669 99.9768 83.7997 97.4628C87.849 88.4005 92.5282 79.629 97.804 71.2111C98.7443 69.6919 100.448 66.8412 101.496 65.4867C102.216 64.5542 104.978 62.1096 105.999 61.1841C108.189 59.219 110.408 57.2842 112.654 55.3807C113.637 54.5316 115.208 52.9617 116.196 52.3053C118.692 50.6497 121.743 47.8863 124.343 46.5552C124.035 47.2584 123.101 48.699 122.67 49.3958C121.727 50.9084 120.811 52.438 119.924 53.9838C116.687 59.6298 113.593 65.356 110.647 71.157C105.93 80.3067 101.499 89.598 97.3606 99.0192C90.8351 113.604 84.9153 128.535 80.3017 143.83C80.1132 144.455 79.8804 145.222 79.7625 145.863C79.4067 147.804 78.6398 149.57 78.149 151.472C77.7852 151.471 77.3518 151.431 77.0233 151.535C75.4552 151.567 73.2703 151.646 71.7354 151.519Z" fill="#4A5E53"/> +<path d="M10 46.4442C10.9073 46.7506 12.6541 47.9953 13.4921 48.5285C21.9482 53.9085 29.4711 60.6157 37.0413 67.1303C38.0921 68.0346 39.0861 69.508 39.968 70.6088C41.0427 71.9505 42.0974 73.347 43.1503 74.712C44.7901 76.8668 46.3926 79.0491 47.9571 81.2583C48.522 82.0499 50.2665 84.42 50.6221 85.1347C51.7801 87.4648 53.1967 90.3256 54.2062 92.7367C55.5482 95.9424 57.4062 99.8028 58.5541 103.023C59.4081 105.419 60.3923 109.179 61.0474 111.698C62.0539 115.569 63.3799 120.056 64.1982 123.944C65.0181 127.84 65.7315 132.633 66.3348 136.566C66.8705 139.979 67.3463 143.401 67.7617 146.831C67.9195 148.147 68.2192 150.261 68.2535 151.539C67.1838 151.594 64.8104 151.446 63.6498 151.368C61.9077 151.252 59.3028 151.185 57.6294 150.904C56.7981 149.445 56.0114 147.571 55.1907 146.054C52.9859 141.982 50.6542 137.501 48.0998 133.662L36.7421 116.937C35.6012 115.292 34.4495 113.653 33.2869 112.022C32.7837 111.312 31.4838 109.559 31.1504 108.863C29.7391 105.922 28.3203 102.782 27.0453 99.7858L24.4782 93.7393C24.0518 92.7507 23.3051 91.2013 22.9849 90.2537C22.3378 88.3394 21.7712 86.2802 21.1559 84.341C19.075 77.7558 17.1022 71.1378 15.2383 64.4894L11.9512 53.2273C11.3289 51.1183 10.4944 48.5511 10 46.4442Z" fill="#36443F"/> +<path d="M10 46.4442C10.9073 46.7506 12.6541 47.9953 13.4921 48.5285C21.9482 53.9085 29.4711 60.6157 37.0413 67.1303C38.0921 68.0346 39.0861 69.508 39.968 70.6088C41.0427 71.9505 42.0974 73.347 43.1503 74.712C44.7901 76.8668 46.3926 79.0491 47.9571 81.2583C48.522 82.0499 50.2665 84.42 50.6221 85.1347C51.7801 87.4648 53.1967 90.3256 54.2062 92.7367C55.5481 95.9424 57.4062 99.8028 58.5541 103.023C59.4081 105.419 60.3923 109.179 61.0474 111.698C62.0539 115.569 63.3799 120.056 64.1982 123.944C65.0181 127.84 65.7315 132.633 66.3348 136.566C66.8705 139.979 67.3463 143.401 67.7617 146.831C67.9195 148.147 68.2192 150.261 68.2535 151.539C67.1838 151.594 64.8104 151.446 63.6498 151.368C61.9077 151.252 56.6963 123.578 48.6876 107.213C40.6832 90.8558 22.9849 68.2554 22.9849 68.2554L11.9512 53.2273C11.3289 51.1183 10.4944 48.5511 10 46.4442Z" fill="#26302E"/> +<path d="M130 95.8984C129.938 96.6631 124.822 107.124 124.126 108.642C122.509 112.162 121.032 115.784 119.311 119.251C117.616 122.668 115.734 126.016 113.948 129.388C113.057 131.068 112.038 133.484 110.924 134.939C109.581 136.69 99.9033 148.128 98.6204 148.848C96.9606 149.78 94.1881 150.121 92.2723 150.397C92.2118 149.802 95.5396 143.353 96.0082 142.386C100.212 133.708 105.186 125.422 110.348 117.279C110.754 116.638 112.076 115.292 112.639 114.666L116.595 110.263C120.852 105.552 125.511 100.395 130 95.8984Z" fill="#2E3F38"/> +<path d="M19.2043 108.281C19.8531 108.629 22.4064 110.637 23.1078 111.176C27.0124 114.181 30.8314 117.294 34.5603 120.511C35.8065 121.582 37.9719 123.337 39.0038 124.447C40.0238 125.544 41.5313 127.606 42.5029 128.838C46.8555 134.358 50.6067 140.307 54.0261 146.432C54.5289 147.333 56.0217 149.87 56.3073 150.76C55.1381 150.859 49.5147 149.788 48.717 148.967C46.1512 146.329 43.8151 143.351 41.2894 140.656C38.754 137.949 36.6936 134.586 34.5917 131.521C31.2726 126.714 28.0136 121.865 24.8155 116.977L21.0478 111.21C20.5004 110.368 19.638 109.129 19.2043 108.281Z" fill="#2E3F38"/> +</g> +<mask id="mask0_1_45" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="10" y="10" width="120" height="142"> +<path d="M55.4088 10C56.0393 10.5081 57.647 12.5621 58.1966 13.2506C61.799 17.7979 65.2999 22.4231 68.6969 27.1231C70.7226 29.9079 72.9327 32.5646 74.269 35.7216C74.79 36.9536 75.4008 38.2079 75.9611 39.4275C78.0623 43.9784 80.1253 48.5467 82.1479 53.1322C83.2807 55.6447 84.3862 58.1686 85.4656 60.7037C85.8868 61.6907 86.7304 63.5737 86.9551 64.5212C87.0912 65.0943 87.1839 67.301 87.2222 67.9856C87.3643 70.6104 87.4903 73.2361 87.6001 75.8624C87.722 78.3932 87.9055 81.4542 87.8097 83.9502C87.7996 84.2051 85.5502 88.8072 85.2792 89.381C83.9348 92.2388 82.6155 95.1087 81.3205 97.9905C80.5899 99.6049 79.8371 101.245 79.1397 102.874C78.8232 103.608 78.5713 104.81 78.3606 105.624L77.1644 110.213C76.4196 113.057 75.7021 115.91 75.0117 118.769C74.7941 119.659 74.5784 120.55 74.3557 121.439C73.7591 123.83 73.5031 126.309 73.1745 128.751C72.6757 132.451 72.2091 136.154 71.7747 139.861C71.5742 142.894 71.1267 146.04 70.8607 149.073C70.7921 149.851 71.0834 152.216 69.5374 151.449C69.2048 151.283 69.1071 146.362 68.9337 145.749C67.9929 136.648 67.0296 127.724 64.7977 118.831C64.1585 116.207 63.4997 113.588 62.8213 110.974C62.0989 108.205 61.3226 104.65 60.3126 102.004C58.8459 98.1605 56.9631 94.333 55.4134 90.5135C54.6413 88.6113 53.6075 86.767 52.9298 84.8248C52.672 84.0421 52.3331 80.4915 52.2137 79.437C51.9446 77.1043 51.7052 74.768 51.4958 72.429C51.3649 70.9958 51.163 69.4066 51.2102 67.982C51.3258 64.4971 51.5463 61.006 51.7727 57.5274C52.1985 50.9002 52.6659 44.2756 53.1752 37.6541C53.611 31.4317 54.0791 25.2116 54.5796 18.9941C54.7996 16.325 54.9958 12.5205 55.4088 10Z" fill="#232C2B"/> +<path d="M69.5374 151.449C68.9878 150.761 79.9164 66.7861 55.4088 10C54.9958 12.5205 54.7996 16.325 54.5796 18.9941C54.0791 25.2116 53.611 31.4317 53.1752 37.6541C52.6659 44.2756 52.1985 50.9002 51.7727 57.5274C51.5463 61.006 51.3258 64.4971 51.2102 67.982C51.163 69.4066 51.3649 70.9958 51.4958 72.429C51.7052 74.768 51.9446 77.1043 52.2137 79.437C52.3331 80.4915 52.672 84.0421 52.9298 84.8248C53.6075 86.767 54.6413 88.6113 55.4134 90.5135C56.9631 94.333 58.8459 98.1605 60.3126 102.004C61.3226 104.65 62.0989 108.205 62.8213 110.974C63.4997 113.588 64.1585 116.207 64.7977 118.831C67.0296 127.724 67.9929 136.648 68.9337 145.749C69.1071 146.362 69.2048 151.283 69.5374 151.449Z" fill="#32453E"/> +<path d="M71.111 89.3149C71.1548 89.2979 71.2755 41.2372 55.4089 10C54.9959 12.5205 54.7997 16.325 54.5797 18.9941C54.0792 25.2116 53.611 31.4317 53.1753 37.6541C52.666 44.2756 51.7728 57.5275 51.7728 57.5275L61.9762 71.602C61.9762 71.602 71.1686 89.2465 71.111 89.3149Z" fill="#44514D"/> +<path d="M124.343 46.5553L124.475 46.4929C124.568 46.8631 122.76 58.509 122.535 60.0715C121.681 66.0692 120.782 72.0606 119.838 78.0453C119.367 81.0735 118.899 84.1842 118.352 87.202C118.208 87.9967 117.55 89.8389 117.269 90.7016L115.342 96.6042C114.194 100.214 112.988 103.805 111.724 107.377C111.264 108.678 110.666 110.746 110.077 111.92C107.873 116.315 105.641 120.712 103.369 125.073C101.911 127.655 100.322 130.21 98.9993 132.864C96.4979 137.881 94.1558 142.985 91.8792 148.107C91.5718 148.798 91.083 150.024 90.6477 150.608C87.9166 150.927 85.1764 151.16 82.4301 151.306C80.7159 151.413 78.7184 151.561 77.0233 151.535C75.4552 151.567 73.2703 151.646 71.7354 151.519C72.4711 144.787 73.3821 138.075 74.4665 131.389C74.7749 129.453 75.0501 127.495 75.3967 125.551C76.2181 120.934 77.6119 116.329 78.796 111.787C79.3433 109.687 79.8784 107.51 80.479 105.449C81.2439 102.828 82.669 99.9769 83.7997 97.4629C87.849 88.4005 92.5282 79.629 97.804 71.2112C98.7443 69.6919 100.448 66.8413 101.496 65.4868C102.216 64.5543 104.978 62.1097 105.999 61.1841C108.189 59.219 110.408 57.2843 112.654 55.3808C113.637 54.5317 115.208 52.9617 116.196 52.3054C118.692 50.6498 121.743 47.8864 124.343 46.5553Z" fill="#25332D"/> +<path d="M71.7354 151.519C72.4711 144.787 73.3821 138.075 74.4665 131.389C74.7749 129.453 75.0501 127.495 75.3967 125.551C76.2181 120.934 77.6119 116.329 78.796 111.787C79.3433 109.687 79.8784 107.51 80.479 105.449C81.2439 102.828 82.669 99.9768 83.7997 97.4628C87.849 88.4005 92.5282 79.629 97.804 71.2111C98.7443 69.6919 100.448 66.8412 101.496 65.4867C102.216 64.5542 104.978 62.1096 105.999 61.1841C108.189 59.219 110.408 57.2842 112.654 55.3807C113.637 54.5316 115.208 52.9617 116.196 52.3053C118.692 50.6497 121.743 47.8863 124.343 46.5552C124.035 47.2584 123.101 48.699 122.67 49.3958C121.727 50.9084 120.811 52.438 119.924 53.9838C116.687 59.6298 113.593 65.356 110.647 71.157C105.93 80.3067 101.499 89.598 97.3606 99.0192C90.8351 113.604 84.9153 128.535 80.3017 143.83C80.1132 144.455 79.8804 145.222 79.7625 145.863C79.4067 147.804 78.6398 149.57 78.149 151.472C77.7852 151.471 77.3518 151.431 77.0233 151.535C75.4552 151.567 73.2703 151.646 71.7354 151.519Z" fill="#4A5E53"/> +<path d="M10 46.4442C10.9073 46.7506 12.6541 47.9953 13.4921 48.5285C21.9482 53.9085 29.4711 60.6157 37.0413 67.1303C38.0921 68.0346 39.0861 69.508 39.968 70.6088C41.0427 71.9505 42.0974 73.347 43.1503 74.712C44.7901 76.8668 46.3926 79.0491 47.9571 81.2583C48.522 82.0499 50.2665 84.42 50.6221 85.1347C51.7801 87.4648 53.1967 90.3256 54.2062 92.7367C55.5482 95.9424 57.4062 99.8028 58.5541 103.023C59.4081 105.419 60.3923 109.179 61.0474 111.698C62.0539 115.569 63.3799 120.056 64.1982 123.944C65.0181 127.84 65.7315 132.633 66.3348 136.566C66.8705 139.979 67.3463 143.401 67.7617 146.831C67.9195 148.147 68.2192 150.261 68.2535 151.539C67.1838 151.594 64.8104 151.446 63.6498 151.368C61.9077 151.252 59.3028 151.185 57.6294 150.904C56.7981 149.445 56.0114 147.571 55.1907 146.054C52.9859 141.982 50.6542 137.501 48.0998 133.662L36.7421 116.937C35.6012 115.292 34.4495 113.653 33.2869 112.022C32.7837 111.312 31.4838 109.559 31.1504 108.863C29.7391 105.922 28.3203 102.782 27.0453 99.7858L24.4782 93.7393C24.0518 92.7507 23.3051 91.2013 22.9849 90.2537C22.3378 88.3394 21.7712 86.2802 21.1559 84.341C19.075 77.7558 17.1022 71.1378 15.2383 64.4894L11.9512 53.2273C11.3289 51.1183 10.4944 48.5511 10 46.4442Z" fill="#36443F"/> +<path d="M10 46.4442C10.9073 46.7506 12.6541 47.9953 13.4921 48.5285C21.9482 53.9085 29.4711 60.6157 37.0413 67.1303C38.0921 68.0346 39.0861 69.508 39.968 70.6088C41.0427 71.9505 42.0974 73.347 43.1503 74.712C44.7901 76.8668 46.3926 79.0491 47.9571 81.2583C48.522 82.0499 50.2665 84.42 50.6221 85.1347C51.7801 87.4648 53.1967 90.3256 54.2062 92.7367C55.5481 95.9424 57.4062 99.8028 58.5541 103.023C59.4081 105.419 60.3923 109.179 61.0474 111.698C62.0539 115.569 63.3799 120.056 64.1982 123.944C65.0181 127.84 65.7315 132.633 66.3348 136.566C66.8705 139.979 67.3463 143.401 67.7617 146.831C67.9195 148.147 68.2192 150.261 68.2535 151.539C67.1838 151.594 64.8104 151.446 63.6498 151.368C61.9077 151.252 56.6963 123.578 48.6876 107.213C40.6832 90.8558 22.9849 68.2554 22.9849 68.2554L11.9512 53.2273C11.3289 51.1183 10.4944 48.5511 10 46.4442Z" fill="#26302E"/> +<path d="M130 95.8984C129.938 96.6631 124.822 107.124 124.126 108.642C122.509 112.162 121.032 115.784 119.311 119.251C117.616 122.668 115.734 126.016 113.948 129.388C113.057 131.068 112.038 133.484 110.924 134.939C109.581 136.69 99.9033 148.128 98.6204 148.848C96.9606 149.78 94.1881 150.121 92.2723 150.397C92.2118 149.802 95.5396 143.353 96.0082 142.386C100.212 133.708 105.186 125.422 110.348 117.279C110.754 116.638 112.076 115.292 112.639 114.666L116.595 110.263C120.852 105.552 125.511 100.395 130 95.8984Z" fill="#2E3F38"/> +<path d="M19.2043 108.281C19.8531 108.629 22.4064 110.637 23.1078 111.176C27.0124 114.181 30.8314 117.294 34.5603 120.511C35.8065 121.582 37.9719 123.337 39.0038 124.447C40.0238 125.544 41.5313 127.606 42.5029 128.838C46.8555 134.358 50.6067 140.307 54.0261 146.432C54.5289 147.333 56.0217 149.87 56.3073 150.76C55.1381 150.859 49.5147 149.788 48.717 148.967C46.1512 146.329 43.8151 143.351 41.2894 140.656C38.754 137.949 36.6936 134.586 34.5917 131.521C31.2726 126.714 28.0136 121.865 24.8155 116.977L21.0478 111.21C20.5004 110.368 19.638 109.129 19.2043 108.281Z" fill="#2E3F38"/> +</mask> +<g mask="url(#mask0_1_45)"> +<rect x="3.62885" y="10.056" width="131.508" height="143.498" fill="url(#paint0_radial_1_45)" fill-opacity="0.53"/> +</g> +</g> +<path d="M437.92 135.573C433.005 135.573 428.499 134.378 424.403 131.989C420.376 129.6 417.167 126.391 414.778 122.364C412.457 118.336 411.296 113.796 411.296 108.745C411.296 103.761 412.457 99.2557 414.778 95.2281C417.167 91.1321 420.376 87.8895 424.403 85.5002C428.499 83.1109 433.005 81.9163 437.92 81.9163C442.903 81.9163 447.409 83.1109 451.436 85.5002C455.464 87.8895 458.673 91.1321 461.062 95.2281C463.451 99.2557 464.646 103.761 464.646 108.745C464.646 113.796 463.451 118.336 461.062 122.364C458.673 126.391 455.464 129.6 451.436 131.989C447.409 134.378 442.903 135.573 437.92 135.573ZM437.92 127.791C441.265 127.791 444.303 126.937 447.033 125.231C449.764 123.524 451.914 121.237 453.484 118.37C455.123 115.503 455.942 112.294 455.942 108.745C455.942 105.127 455.123 101.918 453.484 99.1192C451.914 96.252 449.764 93.9651 447.033 92.2585C444.303 90.5519 441.265 89.6985 437.92 89.6985C434.575 89.6985 431.537 90.5519 428.806 92.2585C426.144 93.9651 423.994 96.252 422.355 99.1192C420.785 101.918 420 105.127 420 108.745C420 112.294 420.785 115.503 422.355 118.37C423.994 121.237 426.144 123.524 428.806 125.231C431.537 126.937 434.575 127.791 437.92 127.791Z" fill="#1A1814"/> +<path d="M356.652 134.549V82.8379H365.459V88.5722C369.486 84.0667 374.811 81.8139 381.433 81.8139C385.392 81.8139 388.874 82.6672 391.877 84.3739C394.881 86.0805 397.202 88.4357 398.84 91.4394C400.547 94.4431 401.4 97.9587 401.4 101.986V134.549H392.697V103.625C392.697 99.2558 391.468 95.8084 389.01 93.2825C386.553 90.7567 383.208 89.4938 378.975 89.4938C376.04 89.4938 373.412 90.1423 371.09 91.4394C368.838 92.7364 366.96 94.5455 365.459 96.8665V134.549H356.652Z" fill="#1A1814"/> +<path d="M319.437 135.471C315.682 135.471 312.371 134.822 309.504 133.525C306.637 132.16 304.384 130.317 302.746 127.996C301.176 125.675 300.391 122.944 300.391 119.804C300.391 114.957 302.234 111.134 305.92 108.335C309.675 105.468 314.727 104.034 321.075 104.034C326.468 104.034 331.383 105.127 335.821 107.311V101.065C335.821 97.1737 334.694 94.2383 332.441 92.2585C330.189 90.2788 326.912 89.289 322.611 89.289C320.154 89.289 317.628 89.6645 315.034 90.4154C312.44 91.098 309.572 92.2244 306.432 93.7945L303.156 87.0362C306.978 85.2613 310.528 83.9643 313.805 83.1451C317.082 82.2576 320.358 81.8139 323.635 81.8139C330.257 81.8139 335.377 83.384 338.995 86.5242C342.613 89.6645 344.422 94.17 344.422 100.041V134.549H335.821V129.736C333.5 131.648 330.974 133.081 328.243 134.037C325.513 134.993 322.577 135.471 319.437 135.471ZM308.787 119.599C308.787 122.33 309.914 124.548 312.167 126.255C314.488 127.893 317.491 128.712 321.178 128.712C324.113 128.712 326.775 128.303 329.165 127.484C331.622 126.596 333.841 125.197 335.821 123.285V113.967C333.773 112.67 331.588 111.748 329.267 111.202C327.014 110.588 324.454 110.281 321.587 110.281C317.696 110.281 314.59 111.134 312.269 112.841C309.948 114.547 308.787 116.8 308.787 119.599Z" fill="#1A1814"/> +<path d="M281.753 134.549V62.8702L290.56 61.027V134.549H281.753Z" fill="#1A1814"/> +<path d="M248.028 135.471C243.044 135.471 238.504 134.31 234.409 131.989C230.381 129.6 227.172 126.391 224.783 122.364C222.394 118.268 221.199 113.694 221.199 108.642C221.199 103.727 222.326 99.2558 224.578 95.2281C226.899 91.2004 230.005 87.9919 233.897 85.6026C237.788 83.2133 242.123 82.0187 246.901 82.0187C251.611 82.0187 255.81 83.2133 259.496 85.6026C263.182 87.9919 266.118 91.2345 268.302 95.3305C270.487 99.3581 271.579 103.864 271.579 108.847V111.509H230.005C230.415 114.581 231.473 117.38 233.18 119.906C234.886 122.364 237.037 124.309 239.631 125.743C242.293 127.176 245.229 127.893 248.437 127.893C251.031 127.893 253.523 127.484 255.912 126.664C258.37 125.845 260.452 124.685 262.159 123.183L267.688 128.917C264.753 131.102 261.681 132.74 258.472 133.832C255.332 134.925 251.85 135.471 248.028 135.471ZM230.108 104.649H262.875C262.397 101.782 261.373 99.2216 259.803 96.9689C258.302 94.6478 256.424 92.8388 254.171 91.5417C251.919 90.2447 249.427 89.5962 246.696 89.5962C243.897 89.5962 241.337 90.2447 239.016 91.5417C236.695 92.7705 234.75 94.5454 233.18 96.8665C231.61 99.1192 230.586 101.713 230.108 104.649Z" fill="#1A1814"/> +<path d="M186.112 135.368C181.265 135.368 176.862 134.174 172.902 131.784C168.943 129.395 165.803 126.221 163.482 122.261C161.161 118.234 160 113.694 160 108.642C160 103.659 161.161 99.1875 163.482 95.2281C165.803 91.2004 168.943 87.9919 172.902 85.6026C176.862 83.2133 181.299 82.0187 186.214 82.0187C189.149 82.0187 191.982 82.4965 194.713 83.4522C197.444 84.408 199.97 85.7733 202.291 87.5482V62.8702L211.097 61.027V134.549H202.393V129.327C197.887 133.354 192.46 135.368 186.112 135.368ZM187.033 127.688C190.173 127.688 193.041 127.108 195.635 125.948C198.297 124.719 200.516 123.046 202.291 120.93V96.3545C200.516 94.3065 198.297 92.7022 195.635 91.5417C193.041 90.3129 190.173 89.6986 187.033 89.6986C183.62 89.6986 180.514 90.5177 177.715 92.1561C174.916 93.7945 172.697 96.0473 171.059 98.9144C169.489 101.713 168.704 104.922 168.704 108.54C168.704 112.158 169.489 115.401 171.059 118.268C172.697 121.135 174.916 123.422 177.715 125.128C180.514 126.835 183.62 127.688 187.033 127.688Z" fill="#1A1814"/> +<defs> +<filter id="filter0_n_1_45" x="10" y="10" width="120" height="141.6" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feTurbulence type="fractalNoise" baseFrequency="1.953538179397583 1.953538179397583" stitchTiles="stitch" numOctaves="3" result="noise" seed="9169" /> +<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" /> +<feComponentTransfer in="alphaNoise" result="coloredNoise1"> +<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/> +</feComponentTransfer> +<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" /> +<feFlood flood-color="rgba(65, 105, 88, 0.44)" result="color1Flood" /> +<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" /> +<feMerge result="effect1_noise_1_45"> +<feMergeNode in="shape" /> +<feMergeNode in="color1" /> +</feMerge> +</filter> +<radialGradient id="paint0_radial_1_45" cx="0" cy="0" r="1" gradientTransform="matrix(0.329188 -58.2809 94.3962 0.524543 75.3902 142.29)" gradientUnits="userSpaceOnUse"> +<stop offset="0.000404775" stop-color="#00C26F" stop-opacity="0.5"/> +<stop offset="1" stop-color="#416958" stop-opacity="0"/> +</radialGradient> +<clipPath id="clip0_1_45"> +<rect width="120" height="141.6" fill="white" transform="translate(10 10)"/> +</clipPath> +</defs> +</svg> diff --git a/.delano/viewer/public/explorer.svg b/.delano/viewer/public/explorer.svg new file mode 100644 index 0000000..b532e91 --- /dev/null +++ b/.delano/viewer/public/explorer.svg @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> +<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M853.333333 256H469.333333l-85.333333-85.333333H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v170.666667h853.333334v-85.333334c0-46.933333-38.4-85.333333-85.333334-85.333333z" fill="#FFA000" /><path d="M853.333333 256H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v426.666667c0 46.933333 38.4 85.333333 85.333334 85.333333h682.666666c46.933333 0 85.333333-38.4 85.333334-85.333333V341.333333c0-46.933333-38.4-85.333333-85.333334-85.333333z" fill="#FFCA28" /></svg> \ No newline at end of file diff --git a/.delano/viewer/public/favicon.png b/.delano/viewer/public/favicon.png new file mode 100644 index 0000000..aff275f Binary files /dev/null and b/.delano/viewer/public/favicon.png differ diff --git a/.delano/viewer/public/index.html b/.delano/viewer/public/index.html new file mode 100644 index 0000000..dfa0ac8 --- /dev/null +++ b/.delano/viewer/public/index.html @@ -0,0 +1,22 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8" /> +<meta name="viewport" content="width=device-width, initial-scale=1" /> +<title>Delano Viewer + + + + + + + + + +
+ + + + + + diff --git a/.delano/viewer/public/markdown.svg b/.delano/viewer/public/markdown.svg new file mode 100644 index 0000000..d884ddf --- /dev/null +++ b/.delano/viewer/public/markdown.svg @@ -0,0 +1,6 @@ + + + markdown + + + \ No newline at end of file diff --git a/.delano/viewer/public/styles.css b/.delano/viewer/public/styles.css new file mode 100644 index 0000000..9177aa9 --- /dev/null +++ b/.delano/viewer/public/styles.css @@ -0,0 +1,1318 @@ +/* ============================================================ + Delano Viewer — Keendoc design language + ============================================================ */ + +/* ---------- Tokens ---------- */ +:root { + --bg: oklch(0.985 0.004 85); + --surface: oklch(0.995 0.003 85); + --ink: oklch(0.21 0.008 80); + --ink-70: oklch(0.40 0.008 80); + --ink-50: oklch(0.55 0.008 80); + --ink-40: oklch(0.66 0.006 80); + --ink-25: oklch(0.82 0.005 80); + + --line: oklch(0.92 0.005 85); + --line-soft: oklch(0.95 0.005 85); + + --accent: oklch(0.52 0.08 245); + --accent-soft: oklch(0.96 0.02 245); + + --ok: oklch(0.62 0.10 155); + --ok-soft: oklch(0.96 0.03 155); + --warn: oklch(0.62 0.13 55); + --warn-soft: oklch(0.97 0.04 60); + --low: oklch(0.70 0.04 85); + --low-soft: oklch(0.96 0.01 85); + + --r: 6px; + --r-sm: 4px; + + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.45; + color: var(--ink); + background: var(--bg); + -webkit-font-smoothing: antialiased; + font-feature-settings: "ss01", "cv11"; +} + +button { font-family: inherit; font-size: inherit; color: inherit; background: none; border: none; cursor: pointer; padding: 0; } +.mono { font-family: var(--font-mono); font-size: 12.5px; letter-spacing: -0.01em; } +.small { font-size: 12px; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ---------- App shell ---------- */ +.app { + display: grid; + grid-template-columns: 232px 1fr; + min-height: 100vh; +} + +/* ---------- Sidebar ---------- */ +.sidebar { + border-right: 1px solid var(--line); + background: var(--surface); + padding: 20px 14px 24px; + display: flex; + flex-direction: column; + gap: 4px; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} +.brand { + display: flex; + align-items: center; + padding: 0 10px +} +.brand-logo { + display: block; + width: min(110px, 100%); + height: auto; +} +.brand-mark { + width: 26px; height: 26px; + display: grid; place-items: center; + border: 1px solid var(--line); + border-radius: var(--r-sm); + background: var(--bg); + color: var(--ink); +} +.brand-name { font-weight: 600; letter-spacing: -0.01em; font-size: 15px; } + +.nav { display: flex; flex-direction: column; gap: 1px; } +.nav-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 9px; + border-radius: var(--r-sm); + color: var(--ink-70); + text-align: left; + transition: background 90ms, color 90ms; +} +.nav-item:hover { background: var(--line-soft); color: var(--ink); } +.nav-item.is-active { + background: var(--ink); + color: var(--bg); +} +.nav-item.is-active .nav-ico { color: var(--bg); } +.nav-ico { width: 16px; display: grid; place-items: center; color: var(--ink-50); } +.nav-item-count { + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; +} +.nav-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.nav-count { + min-width: 22px; + height: 18px; + display: inline-grid; + place-items: center; + padding: 0 6px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--bg); + color: var(--ink-50); + font-size: 10.5px; + line-height: 1; +} +.nav-item.is-active .nav-count { + color: var(--bg); + border-color: color-mix(in oklch, var(--bg) 32%, transparent); + background: color-mix(in oklch, var(--bg) 14%, transparent); +} + +.nav-section { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-40); + padding: 22px 9px 6px; + font-weight: 500; +} + +.sidebar-foot { margin-top: auto; padding-top: 16px; border-top: 1px solid var(--line); } + +.project-select-v3 { + display: grid; + grid-template-columns: 26px minmax(0, 1fr); + gap: 8px; + align-items: center; + padding: 2px 0 4px; +} +.project-select-mark { + width: 26px; + height: 26px; + display: grid; + place-items: center; + border: 1px solid var(--line); + border-radius: var(--r-sm); + background: var(--bg); + color: var(--ink-50); +} +.project-select-control { + width: 100%; + min-width: 0; + appearance: none; + border: 0; + border-bottom: 1px dashed var(--ink-25); + border-radius: 0; + background: + linear-gradient(45deg, transparent 50%, var(--ink-50) 50%) calc(100% - 11px) 14px / 5px 5px no-repeat, + linear-gradient(135deg, var(--ink-50) 50%, transparent 50%) calc(100% - 6px) 14px / 5px 5px no-repeat, + transparent; + color: var(--ink); + font: 600 13.5px/1.25 var(--font-sans); + padding: 7px 24px 7px 0; + cursor: pointer; + text-overflow: ellipsis; +} +.project-select-control:hover { border-bottom-color: var(--ink); } +.project-select-control:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-bottom-color: transparent; +} + +/* ---------- Topbar ---------- */ +.main { min-width: 0; display: flex; flex-direction: column; } +.topbar { + position: sticky; top: 0; z-index: 5; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 24px; + padding: 14px 32px; + background: color-mix(in oklab, var(--bg) 90%, transparent); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); +} +.tb-project { display: flex; align-items: center; gap: 10px; min-width: 0; } +.tb-title { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tb-meta { display: flex; align-items: center; gap: 12px; color: var(--ink-50); font-size: 12.5px; } +.tb-meta strong { color: var(--ink); font-weight: 500; } +.tb-sep { width: 3px; height: 3px; background: var(--ink-25); border-radius: 50%; } +.tb-readonly { + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 8px; + border: 1px solid var(--line); + border-radius: 999px; + font-size: 11.5px; + color: var(--ink-50); + background: var(--bg); +} +.tb-actions { display: flex; gap: 8px; } +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 11px; + border: 1px solid var(--line); + border-radius: var(--r-sm); + background: var(--bg); + font-size: 13px; + color: var(--ink-70); + transition: background 90ms, border-color 90ms, color 90ms; +} +.btn:hover { background: var(--line-soft); color: var(--ink); border-color: var(--ink-25); } +.btn:disabled { + cursor: default; + color: var(--ink-40); + background: var(--line-soft); + border-color: var(--line); +} +.btn-primary { + background: var(--ink); color: var(--bg); border-color: var(--ink); +} +.btn-primary:hover { background: oklch(0.13 0.008 80); color: var(--bg); } +.copy-btn { + flex: none; + width: 22px; + height: 22px; + display: inline-grid; + place-items: center; + border: 1px solid transparent; + border-radius: var(--r-sm); + color: var(--ink-40); + text-decoration: none; + transition: background 90ms, border-color 90ms, color 90ms; +} +.copy-btn:hover, +.copy-btn:focus-visible { + background: var(--line-soft); + border-color: var(--line); + color: var(--ink); + outline: none; +} +.copy-btn.is-copied { + background: var(--ok-soft); + border-color: color-mix(in oklch, var(--ok) 30%, var(--line)); + color: var(--ok); +} +.copy-btn svg { pointer-events: none; } + +/* ---------- Page ---------- */ +.content { padding: 36px 48px 64px; max-width: 1320px; width: 100%; align-self: center; } +.page { display: flex; flex-direction: column; gap: 36px; } +.page-title { font-size: 26px; font-weight: 600; letter-spacing: -0.018em; margin: 0; } + +.overview-v1 { + gap: 35px; +} +.overview-v1-head { + display: grid; + gap: 18px; +} +.overview-signal-strip { + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.signal-pill { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface); + color: var(--ink-70); +} +.signal-pill-warn { + border-color: color-mix(in oklch, var(--warn) 35%, var(--line)); + background: var(--warn-soft); + color: var(--ink); +} +.signal-color-filled .signal-pill { + border-color: var(--line); + background: var(--surface); + color: var(--ink-70); +} +.signal-color-filled .signal-pill-warning.has-count, +.signal-color-filled .signal-pill-blocker.has-count { + border-color: color-mix(in oklch, var(--warn) 35%, var(--line)); + background: var(--warn-soft); + color: var(--ink); +} +.signal-color-filled .signal-pill-warning.has-count .mono, +.signal-color-filled .signal-pill-blocker.has-count .mono { + color: var(--warn); + font-weight: 600; +} +.signal-color-filled .signal-pill-validation .mono, +.signal-color-filled .signal-pill-progress .mono { + color: var(--ink-70); + font-weight: 500; +} +.overview-priority { + display: block; +} +.overview-delivery { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); + gap: 42px; + align-items: start; +} +.delivery-panel { + min-width: 0; +} +.delivery-list { + display: flex; + flex-direction: column; +} +.delivery-row, +.delivery-task-row { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + gap: 16px; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--line-soft); + text-align: left; +} +.delivery-task-row { + grid-template-columns: minmax(0, 1.4fr) minmax(110px, 0.8fr) max-content; +} +.delivery-row:last-child, +.delivery-task-row:last-child { + border-bottom: none; +} +.delivery-row:hover .delivery-title { + border-bottom-color: var(--ink); +} +.delivery-row-main, +.delivery-row-right { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 10px; +} +.delivery-row-main { + flex-direction: column; + align-items: flex-start; + gap: 3px; +} +.delivery-title { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + border-bottom: 1px dashed transparent; + line-height: 1.25; +} +.delivery-meta, +.delivery-count { + color: var(--ink-50); + font-size: 12px; +} +.delivery-more { + align-self: flex-start; + margin-top: 8px; + color: var(--ink-50); + font-size: 12.5px; + border-bottom: 1px dashed var(--ink-25); +} +.delivery-more:hover { + color: var(--ink); + border-bottom-color: var(--ink); +} +.preview-list { + display: grid; + gap: 10px; + padding-top: 15px; +} +.preview-row { + display: grid; + grid-template-columns: max-content minmax(0, 1.4fr) minmax(0, 1fr); + gap: 14px; + align-items: center; + padding: 11px 0; + border-bottom: 1px solid var(--line-soft); +} +.preview-row-warn { + border-bottom-color: color-mix(in oklch, var(--warn) 20%, var(--line-soft)); +} +.preview-row-progress { + grid-template-columns: minmax(116px, max-content) minmax(160px, 0.55fr) minmax(0, 1.45fr); + gap: 8px; + align-items: start; + padding: 6px 0 7px; +} +.preview-meta-time { + padding-top: 1px; + color: var(--ink-50); + white-space: nowrap; +} +.preview-row-progress .link { + justify-self: start; + text-align: left; + line-height: 1.25; +} +.preview-copy { + color: var(--ink-70); + line-height: 1.45; +} +.preview-list-validation { + gap: 10px; + padding-top: 18px; +} +.validation-row { + grid-template-columns: max-content max-content minmax(0, 1fr); + gap: 17px; + align-items: center; + padding: 12px 0; +} +.validation-row .link { + justify-self: start; + max-width: 34ch; + text-align: left; +} +.source-nav-v2 { + gap: 1px; +} +.source-nav-v2 .nav-break { + margin-top: 11px; + padding: 11px 10px 4px; + border-top: 1px solid var(--line-soft); + color: var(--ink-40); + font: 500 10px/1 var(--font-mono); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.back { + display: inline-flex; align-items: center; gap: 6px; + color: var(--ink-50); font-size: 13px; + margin-bottom: -14px; +} +.back:hover { color: var(--ink); } +.ws-eyebrow { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; + color: var(--ink-40); margin-bottom: -22px; +} + +/* ---------- Summary strip ---------- */ +.summary { + display: grid; + grid-template-columns: repeat(4, 1fr); + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} +.summary > .field { + padding: 16px 22px; + border-right: 1px solid var(--line); +} +.summary > .field:last-child { border-right: none; } +.summary > .field:first-child { padding-left: 0; } +.summary-tight > .field { padding: 12px 22px; } +.summary-tight > .field:first-child { padding-left: 0; } +.doc-reader-page { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 400px); + gap: 44px; + align-items: start; +} +.doc-reader-main { + min-width: 0; +} +.doc-reader-main .md-body { + margin-top: 34px; +} +.content-reader-head-c .doc-reader-main .back { + margin-bottom: 22px; +} +.content-reader-head-c .doc-reader-main .ws-eyebrow { + margin-bottom: 6px; +} +.content-reader-head-c .doc-reader-main .page-title { + max-width: 100%; +} +.doc-meta-panel { + position: sticky; + top: 86px; + border: 1px solid var(--line); + border-radius: var(--r); + background: var(--surface); + overflow: hidden; +} +.doc-side { + min-width: 400px; + position: sticky; + top: 86px; + display: flex; + flex-direction: column; + gap: 20px; +} +.doc-side .doc-meta-panel { + position: static; + top: auto; +} +.doc-meta-title { + padding: 12px 14px; + border-bottom: 1px solid var(--line); + font-size: 13px; + font-weight: 600; +} +.doc-meta-list { + max-height: calc(100vh - 150px); + overflow: auto; +} +.doc-meta-list dd { + overflow-wrap: anywhere; +} +.copy-value { + min-width: 0; + overflow-wrap: anywhere; +} + +.field-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-40); + font-weight: 500; + margin-bottom: 6px; +} +.field-value { font-size: 14px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.field-id { color: var(--ink-50); } + +/* health */ +.health { display: flex; align-items: center; gap: 10px; } +.health-bar { + position: relative; + width: 80px; height: 6px; + border-radius: 999px; + background: var(--line); + overflow: hidden; +} +.health-fill { position: absolute; inset: 0; right: auto; background: var(--warn); border-radius: inherit; } +.health-label { font-size: 12.5px; color: var(--ink-70); } + +/* ---------- Block ---------- */ +.block { display: flex; flex-direction: column; } + +.section-head { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 0 12px; + border-bottom: 1px solid var(--line); +} +.section-head.is-collapsible { cursor: pointer; user-select: none; } +.section-head.is-collapsible:hover .caret { color: var(--ink); } +.section-title { display: flex; align-items: baseline; gap: 10px; font-weight: 600; font-size: 16px; letter-spacing: -0.01em; } +.section-title .count { color: var(--ink-40); font-weight: 500; font-size: 13px; } +.section-right { display: flex; align-items: center; gap: 14px; color: var(--ink-50); } +.caret { display: grid; place-items: center; color: var(--ink-50); transition: color 90ms; } +.link-muted { display: inline-flex; align-items: center; gap: 4px; font-size: 12.5px; color: var(--ink-50); cursor: pointer; } +.link-muted:hover { color: var(--ink); } + +/* ---------- Table ---------- */ +.table { display: flex; flex-direction: column; } +.tr { + display: grid; + grid-template-columns: 2fr 1.1fr 1fr 0.9fr 1.5fr 1.2fr; + gap: 20px; + padding: 14px 0; + border-bottom: 1px solid var(--line-soft); + align-items: center; + font-size: 13.5px; +} +.tr > div { min-width: 0; } +.table-3 .tr { grid-template-columns: 2.4fr 1fr 2fr; } +.table-4 .tr { grid-template-columns: 2.5fr 1.2fr 1fr 1.5fr; } +.table-list .tr { grid-template-columns: 2fr 1fr 1fr 1.5fr; } +.table-workspace .tr { grid-template-columns: 2.2fr 1.4fr 1.3fr 0.9fr; } +.table-workspace-warnings .tr { grid-template-columns: 0.8fr 1.4fr 2.2fr 1fr; } +.tr.th { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--ink-40); font-weight: 500; + padding: 12px 0 10px; + border-bottom: 1px solid var(--line); +} +.tr:last-child { border-bottom: none; } +.td-primary { color: var(--ink); font-weight: 500; } +.td-muted { color: var(--ink-50); } +.td-muted-inline { color: var(--ink-50); margin-left: 6px; } + +.link { + color: var(--ink); text-decoration: none; + border-bottom: 1px dashed var(--ink-25); + padding: 0; line-height: 1.2; + transition: border-color 90ms, color 90ms; +} +.link:hover { border-bottom-color: var(--ink); } +.table .link, +.preview-row .link, +.delivery-task-row .link, +.workspace-line .link { + display: inline-block; + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + vertical-align: bottom; +} +.table .td-primary .link, +.preview-row .link, +.delivery-task-row .link { + justify-self: start; +} + +/* ---------- Workspace dashboard ---------- */ +.project-filter { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border: 1px solid var(--line); + border-radius: var(--r-sm); + background: var(--surface); +} +.project-filter-option { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 26px; + padding: 4px 8px; + border-radius: 3px; + color: var(--ink-50); + font-size: 12.5px; + line-height: 1; + transition: background 90ms, color 90ms; +} +.project-filter-option:hover { + background: var(--line-soft); + color: var(--ink); +} +.project-filter-option.is-active { + background: var(--ink); + color: var(--bg); +} +.project-filter-option .mono { + font-size: 11px; + color: currentColor; + opacity: 0.72; +} +.project-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 14px; + padding-top: 16px; +} +.project-card { + display: grid; + gap: 16px; + min-width: 0; + padding: 15px; + border: 1px solid var(--line); + border-radius: var(--r); + background: var(--surface); + text-align: left; + transition: border-color 90ms, background 90ms; +} +.project-card:hover { + border-color: var(--ink-25); + background: color-mix(in oklch, var(--surface) 72%, var(--bg)); +} +.project-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + min-width: 0; +} +.project-card-title { + min-width: 0; + font-weight: 600; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.project-card-dates, +.project-card-stats { + display: grid; + gap: 8px; +} +.project-card-dates { + grid-template-columns: 1fr 1fr; + color: var(--ink-50); + font-size: 12px; +} +.project-card-dates span span { + display: block; + margin-bottom: 2px; + color: var(--ink-40); + font: 500 10px/1.2 var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; +} +.project-card-stats { + grid-template-columns: repeat(4, minmax(0, 1fr)); + padding-top: 12px; + border-top: 1px solid var(--line-soft); + color: var(--ink-50); + font-size: 11.5px; +} +.project-card-stats strong { + display: block; + color: var(--ink); + font-size: 15px; + font-weight: 600; +} + +/* avatars */ +.avatar { + display: inline-grid; place-items: center; + width: 22px; height: 22px; + border-radius: 50%; + background: var(--line); + color: var(--ink-70); + font-size: 10.5px; font-weight: 600; + letter-spacing: 0; +} + +/* ---------- Chips ---------- */ +.chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 9px 3px 7px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface); + font-size: 12px; + color: var(--ink-70); + white-space: nowrap; + font-weight: 500; +} +.chip-dot { + width: 6px; height: 6px; + border-radius: 50%; + background: var(--ink-40); + flex: none; +} +.chip-ok { background: var(--ok-soft); border-color: color-mix(in oklab, var(--ok) 30%, var(--line)); color: oklch(0.38 0.10 155); } +.chip-ok .chip-dot { background: var(--ok); } +.chip-warn { background: var(--warn-soft); border-color: color-mix(in oklab, var(--warn) 30%, var(--line)); color: oklch(0.40 0.13 55); } +.chip-warn .chip-dot { background: var(--warn); } +.chip-low { background: var(--low-soft); border-color: var(--line); color: var(--ink-70); } +.chip-low .chip-dot { background: var(--low); } + +.dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 8px; vertical-align: middle; transform: translateY(-1px); } +.dot-warn { background: var(--warn); } + +/* ---------- Timeline ---------- */ +.timeline { padding: 8px 0 4px; } +.tl-row { + display: grid; + grid-template-columns: 64px 24px 1fr; + align-items: flex-start; + padding: 10px 0; + position: relative; +} +.tl-date { color: var(--ink-50); padding-top: 1px; } +.tl-bullet { + position: relative; + display: grid; place-items: center; + height: 18px; +} +.tl-bullet::before { + content: ""; position: absolute; top: 0; bottom: -32px; + left: 50%; width: 1px; background: var(--line); +} +.tl-row:last-child .tl-bullet::before { display: none; } +.tl-bullet > span { + width: 7px; height: 7px; border-radius: 50%; + background: var(--bg); border: 1.5px solid var(--ink-50); + position: relative; z-index: 1; +} +.tl-body { padding-bottom: 4px; } +.timeline-workspace { margin-top: 2px; } +.workspace-line { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} +.pagination { + display: flex; + align-items: center; + gap: 12px; + padding-top: 16px; + border-top: 1px solid var(--line-soft); + color: var(--ink-50); +} + +/* ---------- Project outline ---------- */ +.outline-list { + display: flex; + flex-direction: column; + padding-top: 8px; +} +.outline-row { + border-bottom: 1px solid var(--line-soft); + padding: 12px 0; +} +.outline-row:last-child { border-bottom: none; } +.outline-main { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 8px 0; + text-align: left; + color: var(--ink); +} +.outline-main:hover .outline-title, +.outline-subitem:hover span:nth-child(2) { + border-bottom-color: var(--ink); +} +.outline-title { + font-size: 15px; + font-weight: 500; + line-height: 1.35; + border-bottom: 1px dashed transparent; +} +.outline-sublist { + display: flex; + flex-direction: column; + gap: 2px; + margin: 4px 0 0 16px; + padding-left: 14px; + border-left: 1px solid var(--line); +} +.outline-subitem { + display: grid; + grid-template-columns: 58px minmax(0, 1fr) max-content; + gap: 12px; + align-items: center; + padding: 7px 0; + text-align: left; + color: var(--ink); + font-size: 13px; +} +.outline-subitem .mono { + color: var(--ink-50); + font-size: 11.5px; +} +.outline-subitem span:nth-child(2) { + min-width: 0; + border-bottom: 1px dashed transparent; + line-height: 1.35; +} + +/* ---------- Workstream detail ---------- */ +.two-col { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 48px; + align-items: start; +} + +.ws-block { padding: 18px 0; border-bottom: 1px solid var(--line); } +.ws-block:first-child { padding-top: 8px; } +.ws-block:last-child { border-bottom: none; } +.ws-h { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ink-50); + margin: 0 0 10px; +} +.ws-body p { margin: 0; color: var(--ink); max-width: 70ch; } +.ws-body { font-size: 14px; line-height: 1.55; } + +.bullets { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; } +.bullets li { padding-left: 18px; position: relative; max-width: 72ch; } +.bullets li::before { + content: ""; position: absolute; left: 4px; top: 9px; + width: 4px; height: 1px; background: var(--ink-40); +} + +.checklist { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 8px; } +.checklist li { display: flex; align-items: center; gap: 10px; min-width: 0; } +.checklist .cb { + width: 14px; height: 14px; + border: 1px solid var(--ink-25); + border-radius: 3px; + display: grid; place-items: center; + flex: none; + color: transparent; +} +.checklist li.done .cb { + background: var(--ink); border-color: var(--ink); color: var(--bg); +} +.checklist li.done { color: var(--ink-50); text-decoration: line-through; text-decoration-thickness: 1px; } +.checklist .link { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + white-space: nowrap; +} +.checklist-meta { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 8px; + flex: none; + color: var(--ink-50); +} +.task-id-copy { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--ink-50); +} + +.decisions { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; } +.decisions li { + display: flex; align-items: baseline; justify-content: space-between; + gap: 24px; padding: 8px 0; border-top: 1px dashed var(--line); +} +.decisions li:first-child { border-top: none; padding-top: 0; } + +/* ---------- Side column ---------- */ +.col-side { display: flex; flex-direction: column; gap: 20px; position: sticky; top: 80px; } +.side-block { + border: 1px solid var(--line); + border-radius: var(--r); + background: var(--surface); + overflow: hidden; +} +.side-head { + display: flex; align-items: center; gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid var(--line); + font-weight: 600; + font-size: 13px; + letter-spacing: -0.005em; +} +.side-head .count { color: var(--ink-40); font-weight: 500; font-size: 12px; } +.side-link { margin-left: auto; } +.side-label { + padding: 10px 14px 5px; + color: var(--ink-50); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.side-label-list { + display: flex; + align-items: center; + gap: 8px; + border-top: 1px solid var(--line-soft); + margin-top: 4px; +} +.side-label .count { + color: var(--ink-40); + font-size: 11px; +} +.task-parent-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 5px 14px 11px; + color: var(--ink); + text-align: left; +} +.task-parent-link span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-bottom: 1px dashed transparent; +} +.task-parent-link:hover span { + border-bottom-color: var(--ink); +} +.task-nav-actions { + padding: 0 14px 10px; +} + +.dl { + margin: 0; padding: 8px 0; + display: flex; + flex-direction: column; + gap: 0; +} +.dl dt { + width: 100%; + padding: 8px 14px 0; + font-size: 12px; + color: var(--ink-50); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.dl dt > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.dl dd { + margin: 0; + padding: 4px 14px 8px; + font-size: 13px; + color: var(--ink); + display: flex; align-items: center; + gap: 6px; + flex-wrap: wrap; + flex: 1; + min-width: 0; +} + +.side-list { margin: 0; padding: 0; list-style: none; } +.side-list li { + padding: 0; + border-top: 1px solid var(--line-soft); + font-size: 13px; + color: var(--ink); +} +.side-list li:first-child { border-top: none; } +.side-list li:hover { background: color-mix(in oklab, var(--line-soft) 60%, transparent); } +.side-list li.is-current { + background: var(--accent-soft); +} +.side-list-button { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 14px; + text-align: left; +} +.side-list-button:disabled { + cursor: default; +} +.side-list-empty { + padding: 10px 14px !important; + color: var(--ink-50); +} +.sl-name { display: inline-flex; align-items: center; gap: 8px; min-width: 0; } +.sl-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sl-name svg { color: var(--ink-50); flex: none; } +.sl-right { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-40); } +.meta-separator { + color: var(--ink-40); + margin-right: 2px; +} + +/* ---------- Page foot ---------- */ +.page-foot { + margin-top: 8px; + padding-top: 24px; + border-top: 1px dashed var(--line); + color: var(--ink-40); + font-size: 11.5px; + letter-spacing: 0.02em; +} + +/* ---------- Search input ---------- */ +.search-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: var(--r-sm); + background: var(--surface); + color: var(--ink); + font-family: inherit; + font-size: 14px; + outline: none; + transition: border-color 150ms, background 150ms; +} +.search-input::placeholder { color: var(--ink-40); } +.search-input:focus { border-color: var(--ink-25); background: var(--bg); } + +/* ---------- Empty state ---------- */ +.empty-state { + padding: 18px 0; + color: var(--ink-50); + font-size: 13.5px; +} + +/* ============================================================ + Markdown body (document reader) + ============================================================ */ +.md-body { + max-width: 780px; + font-size: 15px; + line-height: 1.65; + color: var(--ink); +} + +.md-body h1, .md-body h2, .md-body h3, .md-body h4 { + color: var(--ink); + line-height: 1.15; + letter-spacing: -0.012em; +} +.md-body h1 { font-size: 24px; font-weight: 600; margin: 1.7em 0 0.55em; } +.md-body h2 { font-size: 20px; font-weight: 600; margin: 1.5em 0 0.5em; } +.md-body h3 { font-size: 16px; font-weight: 600; margin: 1.4em 0 0.45em; } +.md-body h4 { font-size: 14px; font-weight: 600; margin: 1.3em 0 0.4em; } +.md-body h5 { + font-family: var(--font-mono); + font-size: 12px; font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--ink-50); + margin: 1.3em 0 0.4em; +} +.md-body h6 { + font-family: var(--font-mono); + font-size: 11px; font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-40); + margin: 1.2em 0 0.35em; +} + +.md-body p { margin: 0 0 1em; } +.md-body ul, .md-body ol { padding-left: 1.5em; margin: 0 0 1em; } +.md-body ul ul, .md-body ol ol, .md-body ul ol, .md-body ol ul { margin: 0.35em 0 0.1em; } +.md-body li { margin: 0.32em 0; } +.md-body li > p:last-child { margin-bottom: 0; } + +.md-body code { + background: var(--line-soft); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 0.1em 0.35em; + font-family: var(--font-mono); + font-size: 0.88em; +} +.md-body pre { + position: relative; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r); + padding: 18px; + overflow: auto; + margin: 1.3em 0; +} +.md-body pre code { + background: transparent; + border: 0; + padding: 0; + font-size: 13px; + color: inherit; +} +.md-body pre.has-lang code { display: block; margin-top: 18px; } +.md-body .code-lang { + position: absolute; + top: 9px; left: 14px; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--ink-40); + pointer-events: none; + user-select: none; +} + +.md-body blockquote { + margin: 1.4em 0; + padding: 2px 0 2px 20px; + border-left: 2px solid var(--line); + color: var(--ink-50); + font-style: italic; + line-height: 1.55; +} + +.md-body hr { + border: 0; + border-top: 1px solid var(--line); + margin: 2em 0; +} + +.md-body .table-wrap { + margin: 1.3em 0; + border: 1px solid var(--line); + border-radius: var(--r); + overflow-x: auto; +} +.md-body table { border-collapse: collapse; width: 100%; font-size: 13.5px; } +.md-body th, .md-body td { + padding: 11px 14px; + text-align: left; + border-bottom: 1px solid var(--line); + vertical-align: top; +} +.md-body thead th { + background: var(--surface); + font-weight: 600; + color: var(--ink); +} +.md-body tbody tr:last-child td { border-bottom: 0; } +.md-body tbody tr:hover { background: var(--line-soft); } + +.md-body a { + color: var(--ink); + text-decoration: none; + border-bottom: 1px dashed var(--ink-25); + transition: border-color 90ms; +} +.md-body a:hover { border-bottom-color: var(--ink); } +.md-body strong { color: var(--ink); font-weight: 600; } + +.md-body .task-list, .md-body .task-list .task-list { list-style: none; padding-left: 0.2em; } +.md-body .task-item { display: flex; align-items: flex-start; gap: 10px; } +.md-body .task-marker { + flex: 0 0 16px; width: 16px; height: 16px; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid var(--line); + border-radius: var(--r-sm); + background: var(--surface); + color: var(--ok); + margin-top: 3px; +} +.md-body .task-item.checked .task-marker { + background: var(--ok-soft); + border-color: var(--ok-soft); +} +.md-body .task-text { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.md-body .task-item.checked .task-text { color: var(--ink-50); } + +/* ---------- Responsive ---------- */ +@media (max-width: 1100px) { + .two-col { grid-template-columns: 1fr; } + .col-side { position: static; } +} + +@media (max-width: 860px) { + .app { grid-template-columns: 1fr; } + .sidebar { + position: static; + height: auto; + flex-direction: row; + flex-wrap: wrap; + gap: 4px; + padding: 12px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + .brand { border-bottom: 0; padding-bottom: 4px; margin-bottom: 0; } + .nav { flex-direction: row; flex-wrap: wrap; gap: 2px; } + .nav-section { padding: 6px 9px; width: 100%; } + .sidebar-foot { margin-top: 0; padding-top: 0; border-top: 0; } + .content { padding: 24px 20px 48px; } + .topbar { padding: 12px 20px; gap: 12px; } + .summary { grid-template-columns: 1fr 1fr; } + .overview-delivery { grid-template-columns: 1fr; gap: 24px; } + .overview-priority { grid-template-columns: 1fr; } + .preview-row { grid-template-columns: 1fr; gap: 5px; } + .delivery-task-row { grid-template-columns: 1fr; gap: 5px; } + .project-card-stats { grid-template-columns: 1fr 1fr; } + .doc-reader-page, + .two-col { grid-template-columns: 1fr; } + .doc-side, + .col-side { + min-width: 0; + width: 100%; + position: static; + } + .outline-subitem { grid-template-columns: 1fr; gap: 5px; } +} diff --git a/.delano/viewer/public/vscode.svg b/.delano/viewer/public/vscode.svg new file mode 100644 index 0000000..f4f67b3 --- /dev/null +++ b/.delano/viewer/public/vscode.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.delano/viewer/server.js b/.delano/viewer/server.js new file mode 100644 index 0000000..ab4750d --- /dev/null +++ b/.delano/viewer/server.js @@ -0,0 +1,436 @@ +#!/usr/bin/env node +/* + * Delano read-only markdown viewer. + * Serves .project markdown contracts without writing repo state. + */ +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const url = require('node:url'); +const { spawn, spawnSync } = require('node:child_process'); + +const repoRoot = path.resolve(process.env.DELANO_VIEWER_ROOT || path.resolve(__dirname, '..', '..')); +const projectRoot = path.join(repoRoot, '.project'); +const publicRoot = path.join(__dirname, 'public'); +const DEFAULT_PORT = 3977; +const MAX_PORT = 65535; +const MAX_PORT_ATTEMPTS = 100; +const startPort = normalizePort(process.env.DELANO_VIEWER_PORT || process.env.PORT, DEFAULT_PORT); + +function normalizePort(value, fallback) { + const parsed = Number(value || fallback); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_PORT) return fallback; + return parsed; +} + +function isInside(parent, child) { + const rel = path.relative(parent, child); + return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel)); +} + +function readText(file) { + return fs.readFileSync(file, 'utf8'); +} + +function walkMarkdown(dir) { + if (!fs.existsSync(dir)) return []; + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkMarkdown(full)); + if (entry.isFile() && entry.name.endsWith('.md')) out.push(full); + } + return out.sort((a, b) => a.localeCompare(b)); +} + +function splitFrontmatter(markdown) { + if (!markdown.startsWith('---\n') && !markdown.startsWith('---\r\n')) return { frontmatter: {}, body: markdown }; + const normalized = markdown.replace(/^---\r?\n/, ''); + const close = normalized.search(/\r?\n---\r?\n/); + if (close < 0) return { frontmatter: {}, body: markdown }; + const yaml = normalized.slice(0, close); + const body = normalized.slice(close).replace(/^\r?\n---\r?\n/, ''); + return { frontmatter: parseSimpleYaml(yaml), body }; +} + +function parseScalar(raw) { + const value = raw.trim().replace(/^['"]|['"]$/g, ''); + if (value === '') return ''; + if (/^(true|false)$/i.test(value)) return value.toLowerCase() === 'true'; + if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value); + if (value.startsWith('[') && value.endsWith(']')) { + return value.slice(1, -1).split(',').map((item) => parseScalar(item)).filter((item) => item !== ''); + } + return value; +} + +function parseSimpleYaml(yaml) { + const data = {}; + let currentKey = null; + for (const line of yaml.split(/\r?\n/)) { + const list = line.match(/^\s*-\s+(.*)$/); + if (list && currentKey) { + if (!Array.isArray(data[currentKey])) data[currentKey] = data[currentKey] ? [data[currentKey]] : []; + data[currentKey].push(parseScalar(list[1])); + continue; + } + const pair = line.match(/^([^:#][^:]*):\s*(.*)$/); + if (!pair) continue; + currentKey = pair[1].trim(); + data[currentKey] = pair[2] ? parseScalar(pair[2]) : []; + } + return data; +} + +function firstHeading(body) { + const heading = body.match(/^#\s+(.+)$/m); + return heading ? heading[1].trim() : null; +} + +function snippet(body) { + return body + .replace(/```[\s\S]*?```/g, ' ') + .replace(/^#+\s+/gm, '') + .replace(/[*_`>#\-[\]]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 180); +} + +function relationshipFields(frontmatter) { + const result = {}; + for (const [key, value] of Object.entries(frontmatter)) { + const text = Array.isArray(value) ? value.join(' ') : String(value ?? ''); + const links = [...text.matchAll(/\[\[([^\]]+)\]\]/g)].map((m) => m[1]); + if (links.length) result[key] = links; + } + return result; +} + +function projectSlugFor(rel) { + const match = rel.match(/^projects\/([^/]+)\//); + return match ? match[1] : null; +} + +function artifactRoleFor(rel) { + if (rel.startsWith('context/')) return rel.endsWith('/progress.md') || rel.endsWith('progress.md') ? 'progress' : 'context'; + if (rel.startsWith('templates/')) return 'template'; + if (/\/spec\.md$/.test(rel)) return 'spec'; + if (/\/plan\.md$/.test(rel)) return 'plan'; + if (/\/decisions\.md$/.test(rel)) return 'decision'; + if (/\/progress\.md$/.test(rel) || /\/updates\//.test(rel) || /\/completion-summary\.md$/.test(rel)) return 'progress'; + if (/\/workstreams\/[^/]+\.md$/.test(rel)) return 'workstream'; + if (/\/tasks\/[^/]+\.md$/.test(rel)) return 'task'; + return 'context'; +} + +function codeFromFilename(rel, prefix) { + const base = path.basename(rel, '.md'); + const match = base.match(new RegExp(`^(${prefix}-[A-Za-z0-9]+)`)); + return match ? match[1] : null; +} + +function normalizeWorkstreamId(value) { + if (!value) return null; + const normalized = String(value).trim().toUpperCase(); + return /^WS-[A-Z0-9]+$/.test(normalized) ? normalized : null; +} + +function csvArray(value) { + if (Array.isArray(value)) return value.map(String).filter(Boolean); + if (!value) return []; + return [String(value)]; +} + +function docMeta(file) { + const markdown = readText(file); + const { frontmatter, body } = splitFrontmatter(markdown); + const rel = path.relative(projectRoot, file).replace(/\\/g, '/'); + const stat = fs.statSync(file); + const role = artifactRoleFor(rel); + return { + path: rel, + title: frontmatter.name || firstHeading(body) || path.basename(file, '.md'), + status: frontmatter.status || null, + type: rel.startsWith('context/') ? 'context' : rel.split('/')[0], + project: projectSlugFor(rel), + role, + artifactRole: role, + workstreamId: role === 'workstream' ? codeFromFilename(rel, 'WS') : (role === 'task' ? normalizeWorkstreamId(frontmatter.workstream) : null), + taskId: role === 'task' ? frontmatter.id || codeFromFilename(rel, 'T') : null, + dependsOn: role === 'task' ? csvArray(frontmatter.depends_on) : [], + updated: frontmatter.updated || frontmatter.timestamp || stat.mtime.toISOString(), + frontmatter, + relationships: relationshipFields(frontmatter), + snippet: snippet(body), + size: stat.size, + }; +} + +function words(text) { + return new Set(String(text || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(/\s+/).filter((w) => w.length > 2 && !['and', 'the', 'for', 'with', 'docs'].includes(w))); +} + +function overlapScore(a, b) { + let score = 0; + for (const word of a) if (b.has(word)) score += 1; + return score; +} + +function relateTasksToWorkstreams(projectDocs) { + const workstreams = projectDocs.filter((doc) => doc.role === 'workstream'); + const tasks = projectDocs.filter((doc) => doc.role === 'task'); + const wsById = new Map(workstreams.map((ws) => [ws.workstreamId, ws])); + const wsWords = new Map(workstreams.map((ws) => [ws.path, words(`${ws.workstreamId} ${ws.title} ${ws.snippet}`)])); + for (const task of tasks) { + if (task.workstreamId && wsById.has(task.workstreamId)) { + task.workstreamPath = wsById.get(task.workstreamId).path; + continue; + } + const taskWords = words(`${task.taskId} ${task.title} ${task.snippet}`); + let best = null; + for (const ws of workstreams) { + const score = overlapScore(taskWords, wsWords.get(ws.path)); + if (!best || score > best.score) best = { ws, score }; + } + task.workstreamId = best && best.score > 0 ? best.ws.workstreamId : null; + task.workstreamPath = best && best.score > 0 ? best.ws.path : null; + } +} + +function projectOutline(projectDocs) { + relateTasksToWorkstreams(projectDocs); + const byRole = (role) => projectDocs.filter((doc) => doc.role === role); + const byName = (docs) => docs.slice().sort((a, b) => a.path.localeCompare(b.path)); + const tasks = byName(byRole('task')); + return { + spec: byRole('spec')[0]?.path || null, + plan: byRole('plan')[0]?.path || null, + progress: byRole('progress').map((doc) => doc.path), + decisions: byRole('decision').map((doc) => doc.path), + workstreams: byName(byRole('workstream')).map((ws) => ({ + path: ws.path, + id: ws.workstreamId, + title: ws.title, + status: ws.status, + tasks: tasks.filter((task) => task.workstreamPath === ws.path).map((task) => task.path), + })), + unassignedTasks: tasks.filter((task) => !task.workstreamPath).map((task) => task.path), + }; +} + +function loadIndex() { + const docs = walkMarkdown(projectRoot).map(docMeta); + const projectSlugs = fs.existsSync(path.join(projectRoot, 'projects')) + ? fs.readdirSync(path.join(projectRoot, 'projects'), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort() + : []; + const fixed = [ + { + slug: 'context', + title: 'Project', + status: null, + created: null, + pinned: true, + docs: docs.filter((doc) => doc.path.startsWith('context/')).map((doc) => doc.path), + }, + { + slug: 'templates', + title: 'Templates', + status: null, + created: null, + pinned: true, + docs: docs.filter((doc) => doc.path.startsWith('templates/')).map((doc) => doc.path), + }, + ]; + const projectEntries = projectSlugs.map((slug) => { + const projectDocs = docs.filter((doc) => doc.path.startsWith(`projects/${slug}/`)); + const spec = projectDocs.find((doc) => doc.path.endsWith('/spec.md')); + const plan = projectDocs.find((doc) => doc.path.endsWith('/plan.md')); + const outline = projectOutline(projectDocs); + return { + slug, + title: spec?.frontmatter.name || plan?.frontmatter.name || slug.replace(/-/g, ' '), + status: spec?.frontmatter.status || plan?.frontmatter.status || null, + created: spec?.frontmatter.created || plan?.frontmatter.created || null, + pinned: false, + docs: projectDocs.map((doc) => doc.path), + outline, + }; + }); + // Sort non-pinned project entries by `created` desc; entries without `created` keep their relative order at the end. + projectEntries.sort((a, b) => { + if (!a.created && !b.created) return 0; + if (!a.created) return 1; + if (!b.created) return -1; + return String(b.created).localeCompare(String(a.created)); + }); + const projects = [...fixed, ...projectEntries]; + return { repo: path.basename(repoRoot), generatedAt: new Date().toISOString(), projects, docs }; +} + +function sendJson(res, data) { + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + res.end(JSON.stringify(data, null, 2)); +} + +function projectFileFromRequest(rel) { + const file = path.resolve(projectRoot, String(rel || '')); + if (!String(rel || '').endsWith('.md') || !isInside(projectRoot, file) || !fs.existsSync(file)) return null; + return file; +} + +function windowsPath(file) { + const converted = spawnSync('wslpath', ['-w', file], { encoding: 'utf8' }); + return converted.status === 0 ? converted.stdout.trim() : file; +} + +function commandExists(command) { + const check = spawnSync(process.platform === 'win32' ? 'where' : 'which', [command], { stdio: 'ignore' }); + return check.status === 0; +} + +function openTarget(target, file) { + const isWin = process.platform === 'win32'; + const isMac = process.platform === 'darwin'; + + if (target === 'code') { + if (!commandExists('code')) return { ok: false, error: 'VS Code CLI `code` was not found on PATH.' }; + // On Windows the CLI is `code.cmd`; spawn requires shell:true to resolve PATHEXT. + spawn('code', ['-g', file], { detached: true, stdio: 'ignore', shell: isWin }).unref(); + return { ok: true, target, opened: file }; + } + + if (target === 'explorer') { + const dir = path.dirname(file); + + // Native Windows: launch explorer.exe directly with the directory. + if (isWin) { + spawn('explorer.exe', [dir], { detached: true, stdio: 'ignore' }).unref(); + return { ok: true, target, opened: dir }; + } + + // WSL: explorer.exe is reachable through the mounted Windows path. + const wslExplorer = '/mnt/c/Windows/explorer.exe'; + if (fs.existsSync(wslExplorer)) { + spawn(wslExplorer, [windowsPath(dir)], { detached: true, stdio: 'ignore' }).unref(); + return { ok: true, target, opened: dir }; + } + + // macOS / Linux fall back to `open` / `xdg-open`. + const opener = isMac ? 'open' : 'xdg-open'; + if (!commandExists(opener)) return { ok: false, error: `System opener \`${opener}\` was not found.` }; + spawn(opener, [dir], { detached: true, stdio: 'ignore' }).unref(); + return { ok: true, target, opened: dir }; + } + + return { ok: false, error: 'Unknown open target.' }; +} + +function sendStatic(res, pathname) { + if (pathname === '/favicon.ico') { + const faviconPath = path.join(publicRoot, 'favicon.png'); + if (fs.existsSync(faviconPath)) { + res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'max-age=86400' }); + res.end(fs.readFileSync(faviconPath)); + return; + } + res.writeHead(204, { 'cache-control': 'max-age=86400' }); + res.end(); + return; + } + const file = pathname === '/' ? path.join(publicRoot, 'index.html') : path.join(publicRoot, pathname); + const resolved = path.resolve(file); + if (!isInside(publicRoot, resolved) || !fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { + res.writeHead(404); res.end('Not found'); return; + } + const ext = path.extname(resolved).toLowerCase(); + const mimeMap = { + '.js': 'text/javascript', + '.jsx': 'text/javascript', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + }; + const isText = ext === '.js' || ext === '.jsx' || ext === '.css' || ext === '.svg' || ext === '' || ext === '.html'; + const type = mimeMap[ext] || 'text/html'; + const headers = isText ? { 'content-type': `${type}; charset=utf-8` } : { 'content-type': type }; + res.writeHead(200, headers); + res.end(fs.readFileSync(resolved)); +} + +const server = http.createServer((req, res) => { + try { + const parsed = url.parse(req.url, true); + if (parsed.pathname === '/api/index') return sendJson(res, loadIndex()); + if (parsed.pathname === '/api/doc') { + const rel = String(parsed.query.path || ''); + const file = projectFileFromRequest(rel); + if (!file) { + res.writeHead(404); res.end('Document not found'); return; + } + const markdown = readText(file); + const meta = docMeta(file); + return sendJson(res, { ...meta, markdown, body: splitFrontmatter(markdown).body }); + } + if (parsed.pathname === '/api/open') { + if (req.method !== 'POST') { + res.writeHead(405); res.end('Use POST'); return; + } + const rel = String(parsed.query.path || ''); + const file = projectFileFromRequest(rel); + if (!file) { + res.writeHead(404); res.end('Document not found'); return; + } + const result = openTarget(String(parsed.query.target || ''), file); + if (!result.ok) { + res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(result, null, 2)); return; + } + return sendJson(res, result); + } + return sendStatic(res, decodeURIComponent(parsed.pathname)); + } catch (error) { + res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); + res.end(error.stack || String(error)); + } +}); + +function listenWithPortFallback(server, firstPort, host = '127.0.0.1') { + let port = firstPort; + let attempts = 0; + + const listen = () => { + server.once('error', onError); + server.listen(port, host); + }; + + const onError = (error) => { + if (error.code === 'EADDRINUSE' && port < MAX_PORT && attempts < MAX_PORT_ATTEMPTS) { + attempts += 1; + port += 1; + listen(); + return; + } + + console.error(`Failed to start Delano viewer on ${host}:${port}: ${error.message}`); + process.exitCode = 1; + }; + + const onListening = () => { + server.removeListener('error', onError); + const address = server.address(); + const actualPort = typeof address === 'object' && address ? address.port : port; + const skipped = actualPort !== firstPort ? ` (${firstPort} was unavailable)` : ''; + console.log(`Delano read-only viewer: http://${host}:${actualPort}${skipped}`); + }; + + server.on('listening', onListening); + listen(); +} + +listenWithPortFallback(server, startPort); diff --git a/.gitignore b/.gitignore index 4880aeb..725abf1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,11 @@ node_modules/ npm-debug.log* # Extension build artifacts +dist/ extension/dist/ extension/*.zip +*.crx +*.pem pointa-extension.zip # MCP Server @@ -36,4 +39,4 @@ docs/prd_document.md .config/ # ToDos -TODO.md \ No newline at end of file +TODO.md diff --git a/.project/context/README.md b/.project/context/README.md new file mode 100644 index 0000000..a52bc47 --- /dev/null +++ b/.project/context/README.md @@ -0,0 +1,23 @@ +# Pointa Context Pack + +This folder is the current Delano context pack for Pointa. It should describe the +repo as it exists now: a browser extension plus local Node/MCP server for visual +localhost annotations, issue reports, design capture, and AI coding-agent +integration. Chrome uses the shared `extension/` package directly; Firefox/Zen +uses the generated package under `dist/firefox/`. + +Read order for new work: + +- `project-overview.md` and `project-brief.md` for mission, scope, and open gaps. +- `tech-context.md`, `system-patterns.md`, and `project-structure.md` before code changes. +- `product-context.md` and `gui-testing.md` before product or UI work. +- `project-style-guide.md` and `progress.md` before closeout. + +Current source-of-truth note: no Delano project contracts exist yet under +`.project/projects/`. Until those are created, use `README.md`, `CLAUDE.md`, +`docs/`, `annotations-server/README.md`, `package.json`, +`annotations-server/package.json`, `extension/manifest.json`, and source files as +the active evidence base. + +Validate this pack with `delano validate` and inspect delivery state with +`delano status --brief`. diff --git a/.project/context/gui-testing.md b/.project/context/gui-testing.md new file mode 100644 index 0000000..dce78a4 --- /dev/null +++ b/.project/context/gui-testing.md @@ -0,0 +1,51 @@ +# GUI Testing Policy + +## Enforcement Mode +- Required for extension UI, content script, popup, onboarding, sidebar, toolbar, + annotation, design mode, bug report, performance, inspiration, screenshot, or + Chrome permission changes. +- Advisory for server-only, documentation-only, Delano-only, or release-config + changes unless they affect browser behavior. + +## Smoke Routes +- Unpacked Chrome extension in `chrome://extensions/` with `extension/` + selected, or generated Firefox/Zen package loaded from `dist/firefox/`. +- A localhost app route such as `http://localhost:3000`, `http://localhost:5173`, + or the repo demo page at `http://localhost:8080/testing/demo-app/index.html`. +- Extension popup and sidebar open/close flow. +- Annotation creation, annotation display, annotation deletion or status update, + and URL-filtered annotation loading. +- Bug report recording, performance investigation recording, screenshot capture, + and backend-log toggle when relevant. +- Design mode and inspiration capture when relevant. +- Non-localhost page behavior for sidebar/onboarding/settings when relevant. + +## Console Filtering +- Blocking: uncaught exceptions in the page, content script, background service + worker, or popup that break the tested flow. +- Blocking: failed calls to `http://127.0.0.1:4242` when the server is expected + to be running. +- Blocking: browser extension permission, injection, debugger attach/detach where + supported, or content script load errors on supported localhost pages. +- Non-blocking only when explained: pre-existing app console noise unrelated to + the touched Pointa feature. + +## Evidence Requirements +- Note the browser, extension package/load state, server command, and tested URL. +- Capture screenshots or short recordings for visible UI changes. +- Capture browser console and background service worker errors when debugging + extension behavior. +- For server-backed flows, include `pointa-server status` or the local server + URL health result when relevant. +- For demo fixture verification, note whether `scripts/load-demo.sh` and + `scripts/clear-demo.sh` were used. + +## Design Validation Threshold +- UI must remain usable on realistic localhost pages without covering the target + content in a way that blocks core site interaction. +- Floating toolbar, sidebar, badges, overlays, and modals must not overlap each + other incoherently. +- Text in compact controls must fit at common browser widths and in light/dark + themes. +- Screenshot, responsive capture, and design mode states must preserve enough + visual context for AI agents and humans to understand the issue. diff --git a/.project/context/product-context.md b/.project/context/product-context.md new file mode 100644 index 0000000..142f84d --- /dev/null +++ b/.project/context/product-context.md @@ -0,0 +1,40 @@ +# Product Context + +## Users +- Primary users are developers working on localhost web apps who want precise, + visual feedback loops with AI coding agents. +- Secondary users are maintainers publishing the Chrome extension, Firefox/Zen + package, and `pointa-server` npm package. +- AI coding agents consume Pointa through MCP tools and need URL-filtered, + token-efficient annotation or issue-report data. + +## Core Flows +- Install the Pointa browser extension, configure an MCP-capable editor to run + `npx -y pointa-server` or connect to `http://127.0.0.1:4242/mcp`, then annotate + localhost pages. +- Create annotations by clicking UI elements, storing selectors, messages, + element context, optional images, and status transitions from `pending` to + `in-review` to `done`. +- Capture bug and performance reports with screenshots, console/network/user + interaction timelines, and optional backend logs when the app is launched via + `pointa-server dev` or `pointa dev` documentation examples. +- Use design mode and inspiration capture to record visual changes or external UI + references with richer styling metadata and screenshots. +- Let AI agents read Pointa MCP data, implement code changes in the target + project, and mark annotations or issues for human review. +- Use demo fixtures to load six pending annotations and three active reports + against `testing/demo-app/index.html` for demos and QA. + +## Constraints +- Local-first privacy is central: Pointa stores project feedback and screenshots + locally and should not send data to external services except explicit user + integrations such as Linear issue creation. +- Annotation features are intentionally focused on local development URLs. +- Cross-project safety matters: MCP annotation reads should filter by URL when + multiple localhost projects exist. +- The Chrome `debugger` permission is sensitive and must remain narrowly used and + well justified. Firefox/Zen builds do not expose Chrome CDP-only capabilities. +- Shadow DOM elements are a known limitation for annotation targeting. +- Chrome/Chromium browsers are supported through the Chrome package. +- Firefox/Zen support is available through the generated beta package, with + responsive viewport capture and other CDP-only features unavailable. diff --git a/.project/context/progress.md b/.project/context/progress.md new file mode 100644 index 0000000..8f8d5db --- /dev/null +++ b/.project/context/progress.md @@ -0,0 +1,36 @@ +# Progress + +## What Changed +- Delano has been installed into the repo with `.agents/`, `.project/`, + `.codex/`, `.delano/`, `AGENTS.md`, `HANDBOOK.md`, and `install-delano.sh`. +- Fresh-install validation helpers were adjusted to use canonical + `.agents/scripts` helper paths and tolerate a repo with no Delano task + contracts yet. +- The `.project/context/` installed pack has been replaced with repo-specific + Pointa context based on current docs, package metadata, source layout, and + Delano status. + +## Why It Changed +- The user requested that Delano be added to this repo and then asked to use the + context management skill to set up context correctly. +- The manage-context audit found all required context files still contained + template markers. + +## What Is Next +- Create a Delano project contract under `.project/projects/` when there is a + concrete delivery initiative to track. +- Keep this context pack updated as product or architecture decisions change. +- Use `delano status --brief` to confirm whether project contracts exist before + starting Delano-tracked work. + +## Remaining Risks +- No active Delano project contracts exist yet, so there are no current + workstreams, tasks, decisions, or evidence maps to reconcile. +- Automated test coverage appears limited: root package exposes lint only, and + `annotations-server` currently has a stub `npm test` script. +- Chrome Web Store readiness docs list open submission work, including debugger + permission justification, production console-log cleanup, and store listing + assets. +- Existing docs contain some stale or conflicting wording, such as `pointa dev` + examples while the package CLI is `pointa-server`; verify command aliases + before publishing user-facing instructions. diff --git a/.project/context/project-brief.md b/.project/context/project-brief.md new file mode 100644 index 0000000..4a6ec4f --- /dev/null +++ b/.project/context/project-brief.md @@ -0,0 +1,25 @@ +# Project Brief + +## Problem +- Developers using AI coding agents often lose precision when describing visual + UI feedback, bugs, or performance issues. Pointa closes that gap by capturing + element selectors, screenshots, URL context, timelines, backend logs, and + structured MCP data from local development pages. +- Delano is being used here to make repo context, delivery state, and agent + operating rules explicit before future implementation work continues. + +## Target Outcome +- The current context-management scope is complete when all files under + `.project/context/` describe Pointa's real product, architecture, workflows, + validation expectations, and unresolved gaps without template markers. +- A new agent should be able to resume work by reading this context pack, + `AGENTS.md`, and the repo docs without guessing what the product is or how it + is structured. + +## Scope Boundaries +- In scope: context pack maintenance, Delano runtime validation, future project + contracts in `.project/projects/`, extension/server implementation work, + release workflow maintenance, and evidence-backed product documentation. +- Out of scope for this context refresh: changing Pointa product behavior, + creating Chrome Web Store assets, publishing npm or Chrome releases, opening + remote PRs, or inventing delivery plans without explicit project contracts. diff --git a/.project/context/project-overview.md b/.project/context/project-overview.md new file mode 100644 index 0000000..ce10b8b --- /dev/null +++ b/.project/context/project-overview.md @@ -0,0 +1,30 @@ +# Project Overview + +## Mission +Pointa helps developers point at UI elements in local web apps, leave visual +feedback, capture bugs or performance issues, and let MCP-capable AI coding +agents read the exact context needed to implement changes. + +The repository ships two coupled products: + +- A Chromium Manifest V3 extension in `extension/`. +- A local Node.js MCP and HTTP server published as `pointa-server` from + `annotations-server/`. + +## Active Delivery Scopes +- No `.project/projects/*` Delano project contracts exist yet. +- Delano runtime and context management have been added to this repo under + `.agents/`, `.project/`, `.codex/`, `.delano/`, `AGENTS.md`, and `HANDBOOK.md`. +- Future scoped work should create explicit Delano project contracts before + tracking workstreams or task state. + +## Current Health +- Delano validation passes after context refresh, with the expected warning that + `.claude` compatibility runtime is absent while `.agents` is canonical. +- Product docs and source agree on the main architecture: local-first extension, + server on port `4242`, file-backed user data under `~/.pointa/`, and MCP tools + for annotations and issue reports. +- Main context gaps: no active Delano project/task contracts, no committed + automated test suite beyond lint and a stub server test script, and + Chrome Web Store submission notes still list production readiness items such + as debugger permission justification, store assets, and console-log cleanup. diff --git a/.project/context/project-structure.md b/.project/context/project-structure.md new file mode 100644 index 0000000..d27fb3b --- /dev/null +++ b/.project/context/project-structure.md @@ -0,0 +1,65 @@ +# Project Structure + +## Canonical Boundaries +- `AGENTS.md`: repo entrypoint for agent instructions, Delano runtime pointers, + validation expectations, and privacy/path-safety reminders. +- `HANDBOOK.md`: Delano process and operator handbook installed with the runtime. +- `.project/`: repo-owned delivery truth for context, registry files, templates, + and future project contracts. +- `.agents/`: canonical shared Delano runtime, including PM scripts, skills, + rules, hooks, schemas, fixtures, and validation helpers. +- `.codex/`: Codex hook configuration. Hooks still require Codex feature enablement + and trust approval before they run. +- `.delano/`: Delano local presentation/viewer assets. +- `.claude/`: not present. Delano validation reports this only as compatibility + runtime absence; `.agents/` is canonical. + +## Runtime Areas +- `extension/manifest.json`: Chrome extension metadata, permissions, host + permissions, icons, and service worker declaration. Firefox/Zen manifests are + generated from this source by `scripts/build-firefox-extension.js`. +- `extension/background/`: service worker and API-server bridge logic. +- `extension/content/`: content script entrypoint, CSS, and feature modules for + annotations, design mode, bug/performance recording, sidebar, toolbar, theming, + selectors, inspirations, and replay. +- `extension/popup/`: lightweight popup passthrough and theme assets. +- `annotations-server/bin/cli.js`: `pointa-server` CLI, daemon management, + status/log commands, stdio bridge behavior, and `pointa-server dev`. +- `annotations-server/lib/server.js`: Express API, MCP server, storage, WebSocket + backend log capture, Linear endpoints, and tool handlers. +- `annotations-server/lib/dev-runner.js` and `preload.cjs`: dev-command wrapping + and backend log capture support. +- `scripts/sync-versions.js`: synchronizes root package, server package, and + extension manifest versions. +- `scripts/build-firefox-extension.js`: generates the Firefox/Zen package under + `dist/firefox/` from the shared extension source. +- `scripts/load-demo.sh` and `scripts/clear-demo.sh`: fixture loading and restore + around `~/.pointa/`. + +## Documentation Areas +- `README.md`: product overview, quick start, MCP setup, backend log capture, and + high-level architecture. +- `CLAUDE.md`: detailed repo operating context, release rules, architecture, and + important implementation constraints. +- `annotations-server/README.md`: server installation, CLI commands, MCP + transports, and MCP tool list. +- `docs/DEVELOPMENT.md`: extension loading, reload workflow, debugging, local + server development, and testing checklist. +- `docs/ANNOTATION_DATA_FORMATS.md`: annotation, message, and status schemas. +- `docs/UPDATE_SYSTEM.md`: extension/server update notification behavior and + version compatibility. +- `docs/FIREFOX_PORT.md`, `docs/FIREFOX_RELEASE.md`, and related Firefox docs: + Firefox/Zen packaging, evidence capture, AMO readiness, and release status. +- `testing/DEMO.md`: demo fixture workflow and troubleshooting. +- `CHROME_STORE_SUBMISSION_CHECKLIST.md`: store readiness issues and submission + requirements. + +## Working Notes +- Runtime data under `~/.pointa/` is local user data and should not be committed. +- `annotations-server/.gitignore` ignores `*.json` by default but explicitly + allowlists `package.json` and `package-lock.json`; add exceptions for any new + tracked JSON file in that directory. +- `CHANGELOG.md` and release version numbers are semantic-release managed. Prefer + `scripts/sync-versions.js` for manual version synchronization when necessary. +- `pointa-extension.zip`, logs, temp files, `node_modules/`, and local session + files are ignored artifacts. diff --git a/.project/context/project-style-guide.md b/.project/context/project-style-guide.md new file mode 100644 index 0000000..d2574a5 --- /dev/null +++ b/.project/context/project-style-guide.md @@ -0,0 +1,35 @@ +# Project Style Guide + +## Naming +- Product name: `Pointa`. +- Server package and CLI: `pointa-server`. +- Root package: `pointa`. +- Delano project slugs should be lowercase kebab-case and should describe the + delivery outcome, for example `chrome-store-readiness` or + `mcp-issue-reporting`. +- Keep version numbers synchronized across `package.json`, + `annotations-server/package.json`, and `extension/manifest.json`. + +## Documentation Conventions +- Update this context pack when architecture, release process, runtime + constraints, testing expectations, or product scope change. +- Prefer evidence-backed statements with file references in commit messages, + progress notes, or final summaries. +- Record unresolved uncertainty directly instead of presenting assumptions as + confirmed facts. +- Use "extension" for the Chrome runtime, "server" or `pointa-server` for the + local Node/MCP package, and "Delano project contracts" for files under + `.project/projects/`. + +## Review Expectations +- Run `delano validate` after touching `.agents/`, `.project/`, `AGENTS.md`, or + `HANDBOOK.md`. +- Run `npm run lint` from the repo root for JavaScript lint changes when + dependencies are installed. +- Run `cd annotations-server && npm test` when server packaging or release paths + are touched, noting that the current script is a stub. +- For extension UI/content changes, reload the unpacked extension, refresh the + target localhost page, and test the affected popup, content script, background + service worker, and console paths manually. +- Do not manually edit `CHANGELOG.md` or release version fields unless the task + explicitly requires a version sync via `scripts/sync-versions.js`. diff --git a/.project/context/system-patterns.md b/.project/context/system-patterns.md new file mode 100644 index 0000000..2ccc347 --- /dev/null +++ b/.project/context/system-patterns.md @@ -0,0 +1,42 @@ +# System Patterns + +## Handbook-First Delivery +- Agents should start from `AGENTS.md`, then use this context pack and + `HANDBOOK.md` to understand delivery rules. Delano project contracts should be + created before future scoped initiatives are treated as tracked workstreams. + +## File-Contract-First State +- Pointa product truth currently lives in source files and docs because no + `.project/projects/*` contracts exist yet. Once contracts exist, keep progress, + evidence, decisions, and task state in `.project/projects/project-slug/` and keep this + context pack as durable repo memory. +- User runtime state belongs in `~/.pointa/`, not in the repository. Fixture + scripts may copy data into that directory only with backup/restore behavior. + +## Thin Runtime Wrapping +- The extension has no build step. Do not introduce bundling, transpilation, or + module imports into extension runtime files without an explicit architecture + decision. +- Keep the server's CLI and daemon behavior compatible with existing MCP setup + examples: command-based `npx -y pointa-server`, manual HTTP endpoint + `http://127.0.0.1:4242/mcp`, and server management commands. +- Preserve serialized server writes. Annotation, issue, and inspiration save + paths use promise-lock patterns to prevent concurrent file-write races. + +## Compatibility Without Dual Truth +- `.agents/` is the canonical Delano runtime. `.claude/` compatibility files are + absent and should not become a second source of truth unless explicitly added + for adapter compatibility. +- Root `CLAUDE.md` predates Delano and remains useful repo context, but persistent + cross-agent instructions should be normalized through `AGENTS.md` and + `.project/context/`. + +## Conservative Installation +- Keep Delano installer output repo-local and validation-clean. If `delano install` + refreshes runtime files, run `delano validate` afterward. +- Do not overwrite user data in `~/.pointa/` outside documented demo fixture + workflows. +- Preserve release automation assumptions: conventional commits on `main`, + `annotations-server/package-lock.json` committed, `NPM_TOKEN` configured for + npm publish, and Chrome Web Store publishing performed manually from the GitHub + release zip. diff --git a/.project/context/tech-context.md b/.project/context/tech-context.md new file mode 100644 index 0000000..e28c211 --- /dev/null +++ b/.project/context/tech-context.md @@ -0,0 +1,52 @@ +# Tech Context + +## Stack +- Root package: private Node package named `pointa`, version synced with the + extension and server. +- Extension: Chromium Manifest V3, vanilla JavaScript, CSS, HTML, service worker + in `extension/background/background.js`, content scripts in + `extension/content/`, popup files in `extension/popup/`, and static assets in + `extension/assets/`. +- Server: Node.js ESM package `pointa-server` in `annotations-server/`, using + Express, CORS, multer, WebSocket `ws`, `@modelcontextprotocol/sdk`, + `node-persist`, `commander`, `chalk`, and `@linear/sdk`. +- Release: semantic-release on `main`, GitHub Actions Node 20, npm publishing + from `annotations-server/`, GitHub release asset `pointa-extension.zip`, and + version sync via `scripts/sync-versions.js`. +- Delano: shared runtime under `.agents/`, repo-owned delivery state under + `.project/`, Codex hook config under `.codex/hooks.json`, and local viewer + assets under `.delano/`. + +## Runtime Constraints +- Node.js 18.0.0 or newer is required by `annotations-server/package.json`; CI uses + Node 20. +- The server defaults to `http://127.0.0.1:4242`; the extension has that port + hardcoded in `extension/background/background.js`. `POINTA_PORT` can change + the server port, but the extension must also be updated or configured to match. +- User data is local and lives under `~/.pointa/`, including annotations, issue + reports, archives, screenshots, inspiration captures, config, PID, and logs. +- The extension primarily enables annotation and issue features on localhost or + local development domains: `localhost`, `127.0.0.1`, `0.0.0.0`, `*.local`, + `*.test`, and `*.localhost`. +- Extension code uses vanilla globals and script loading rather than ES module + imports, bundling, or transpilation. +- Server file writes are serialized through save-lock promise chains. Do not + bypass those locks for annotations, issue reports, or inspirations. + +## Integration Points +- MCP transports: HTTP endpoint `/mcp`, SSE support, and stdio mode for editors + that spawn `npx -y pointa-server`. +- Extension-server API includes annotations, images, inspirations, bug reports, + backend logs, AI tool detection, and Linear issue creation endpoints. +- MCP tools include annotation reads and review marking, annotation image reads, + issue report reads, issue rerun/review/resolution transitions, and project URL + filtering guidance. +- Chrome APIs include storage, tabs, activeTab, scripting, and debugger. The + debugger permission is used for screenshot capture and viewport emulation and + must be justified for Chrome Web Store submission. +- Demo/QA fixture workflow: `scripts/load-demo.sh`, + `scripts/clear-demo.sh`, `testing/fixtures/demo/`, and + `testing/demo-app/index.html`. +- Release workflow depends on `NPM_TOKEN`, `GITHUB_TOKEN`, conventional commits, + `annotations-server/package-lock.json`, `.releaserc.json`, and + `.github/workflows/release.yml`. diff --git a/.project/projects/.gitkeep b/.project/projects/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.project/projects/firefox-port/decisions.md b/.project/projects/firefox-port/decisions.md new file mode 100644 index 0000000..b5abd33 --- /dev/null +++ b/.project/projects/firefox-port/decisions.md @@ -0,0 +1,33 @@ +--- +name: Firefox Extension Port +slug: firefox-port +owner: team +created: 2026-05-29T19:06:15Z +updated: 2026-05-29T19:27:25Z +--- + +# Decisions: Firefox Extension Port + +## Active Decisions +- Use generated browser-specific packaging for Firefox instead of making the + current Chrome `extension/manifest.json` serve every browser directly. +- Build Firefox in staged parity: first load/inject/connect/lint, then full core + annotation parity, then Firefox-supported screenshots and evidence capture. +- Treat `chrome.debugger` behavior as Chrome-only until a supported Firefox + replacement is proven. +- Add `web-ext lint` and `web-ext run` to the Firefox validation workflow. +- Use page instrumentation, `webRequest`, visible-tab screenshots, and backend + logs as the Firefox evidence-capture strategy. +- Keep Chrome regression checks as a required gate after shared extension code + changes. + +## Superseded Decisions +- None. + +## Open Decision Questions +- Minimum Firefox version target. +- Firefox desktop only versus desktop plus Android. +- AMO-listed versus self-distributed signed XPI release. +- Whether to add `webRequest` permission for Firefox issue-report timelines. +- Whether to refactor all AMO linter `innerHTML` warnings before first Firefox + submission. diff --git a/.project/projects/firefox-port/plan.md b/.project/projects/firefox-port/plan.md new file mode 100644 index 0000000..e6133de --- /dev/null +++ b/.project/projects/firefox-port/plan.md @@ -0,0 +1,160 @@ +--- +name: Firefox Extension Port +status: done +lead: team +created: 2026-05-29T19:06:15Z +updated: 2026-05-29T21:48:19Z +linear_project_id: +risk_level: high +spec_status_at_plan_time: planned +--- + +# Delivery Plan: Firefox Extension Port + +## What Changed After Probe + +Research found that Firefox can support Pointa's core local-first extension +workflow, but not the Chrome DevTools Protocol portions. The plan now treats the +port as a browser-specific packaging, runtime compatibility, annotation parity, +evidence-capture replacement, QA, and release-readiness project. + +## Technical Context + +- Current extension is Manifest V3 with `background.service_worker`, `action`, + `activeTab`, `storage`, `tabs`, `debugger`, and `scripting`. +- Current background script uses `chrome.scripting` to inject ordered content + modules dynamically. +- Current local server endpoint is hardcoded as `http://127.0.0.1:4242` in the + background script and in a few content fallback paths. +- Current CDP paths use `chrome.debugger` for viewport emulation, CDP screenshot + capture, network recording, log recording, and runtime console recording. +- Firefox tooling baseline from `web-ext lint` against current `extension/`: + 2 errors, 43 warnings. + +## Architecture Decisions + +- Generate a Firefox-specific package from shared extension sources. +- Keep the current Chrome package shape intact. +- Add browser capability checks before debugger-dependent calls. +- Replace CDP-only features with Firefox-supported alternatives where practical; + otherwise degrade explicitly. +- Make `web-ext lint` a required validation gate for the Firefox package. +- Build the most complete Firefox-supported evidence model rather than waiting + for exact CDP parity. + +## Policy and Contract Checks +- [x] `.project` remains the execution source of truth +- [x] Probe decision is explicit +- [x] Evidence gates are defined before handoff +- [x] External sync writes require dry-run or operator approval + +## Generated Artifact Map +- `spec.md`: Created by `delano project create`, then updated from + `research/firefox-extension-port/findings.md`. +- `plan.md`: Created by `delano project create`, then updated from + `research/firefox-extension-port/findings.md`. +- `decisions.md`: Created by `delano project create`, then updated from + `research/firefox-extension-port/findings.md`. +- `workstreams/`: Created with `delano workstream add` for WS-001 through WS-006. +- `tasks/`: Created with `delano task add` for T-001 through T-029. + +## Complexity Exceptions +- CDP parity is the only high-complexity exception. Firefox lacks the Chrome + `debugger` API, so full parity requires product and architecture decisions + rather than mechanical API renaming. + +## Probe-Driven Architecture Changes + +- Add a manifest generation step, for example `extension/manifest.firefox.json` + or a script that writes `dist/firefox/manifest.json`. +- Firefox manifest requirements: + - include `background.scripts` fallback or Firefox-compatible background form; + - include `browser_specific_settings.gecko.id`; + - include `browser_specific_settings.gecko.data_collection_permissions`; + - omit unsupported `debugger` permission; + - consider a `strict_min_version` after selecting the target Firefox channel. +- Add a runtime capability helper for browser/API detection. +- Route screenshot capture through `tabs.captureVisibleTab` for Firefox. +- Disable or replace viewport emulation in Firefox. +- Replace CDP bug/performance network capture with `webRequest` observation if + the added permission is acceptable. +- Replace CDP console/runtime capture with main-world page instrumentation where + feasible; otherwise capture only content-script-visible errors and backend logs. + +## Workstream Design + +- WS-001 Firefox Packaging Baseline: manifest generation, Gecko ID/data + collection keys, permission cleanup, web-ext scripts, lint gate, and package + architecture docs. +- WS-002 Cross-Browser Runtime Compatibility: API namespace/capability helper, + background lifecycle compatibility, injection verification, local API health + checks, debugger guards, and Firefox permission scope. +- WS-003 Annotation Element Anchoring and Screenshots: annotation CRUD, + element-link resilience, visible-tab and element screenshot attachments, image + storage/MCP payload verification, and design/inspiration compatibility. +- WS-004 Firefox Evidence Capture: console instrumentation, page errors, + unhandled rejections, network metadata/failures, backend logs, and evidence + parity/degraded-state mapping. +- WS-005 Feature Parity UX and QA: Firefox-specific unavailable-feature UX, + manual QA matrix, web-ext demo smoke test, Chrome regression check, and AMO + `innerHTML` warning audit. +- WS-006 Firefox Release and Documentation: signing/distribution decision, user + and developer docs, privacy/data collection declaration, and release readiness + report. + +## Milestone Strategy + +- M1: Firefox package lints with zero errors. +- M2: Firefox loads with `web-ext run`, injects Pointa UI on localhost, and + connects to `pointa-server`. +- M3: Core annotation, element anchoring, screenshot attachment, image storage, + and MCP image retrieval work end to end. +- M4: Firefox-supported evidence capture records console logs, page errors, + unhandled rejections, network metadata/failures, backend logs, and degraded + states for unsupported CDP-only behavior. +- M5: Firefox-specific UX, manual QA, Chrome regression checks, AMO risk audit, + docs, privacy declaration, signing path, and release readiness report are + complete. + +## Rollout Strategy + +- Keep Firefox behind a separate package/build target until lint and manual smoke + tests pass. +- Ship internal/test XPI first. +- Decide listed AMO versus self-distributed signed release after MVP validation. +- Do not promote Firefox parity until CDP-dependent feature behavior is clearly + documented. + +## Test Strategy + +- `web-ext lint --source-dir dist/firefox` +- `web-ext run --source-dir dist/firefox` +- Start server with `cd annotations-server && npm run dev` or + `npx pointa-server start`. +- Smoke on `http://localhost:8080/testing/demo-app/index.html` after + `scripts/load-demo.sh`. +- Verify extension popup/action, sidebar/toolbar injection, annotation create, + annotation list, element re-linking, screenshot capture, image upload/MCP + retrieval, settings/onboarding, and server offline behavior. +- Verify console log, page error, unhandled rejection, network failure, backend + log, and screenshot evidence capture where supported. +- Verify CDP-only controls are hidden, disabled, or replaced in Firefox. +- Regression-check Chrome after shared runtime changes. +- Run `delano validate` after Delano artifacts change. + +## Rollback Strategy + +- Firefox work must not change the Chrome manifest or Chrome release path without + separate validation. +- If Firefox feature parity blocks release, keep Firefox build target internal and + ship only documentation or a limited MVP decision record. +- If generated packaging introduces regressions, remove the Firefox build script + and leave shared extension source untouched. + +## Remaining Delivery Risks + +- Full CDP timeline parity may be impossible with standard Firefox extension APIs. +- AMO review may require dynamic HTML construction cleanup. +- Host permission prompts may reduce install conversion unless scoped and + explained well. +- Firefox Android support may require a separate project after desktop MVP. diff --git a/.project/projects/firefox-port/research/firefox-extension-port/findings.md b/.project/projects/firefox-port/research/firefox-extension-port/findings.md new file mode 100644 index 0000000..9ffbd82 --- /dev/null +++ b/.project/projects/firefox-port/research/firefox-extension-port/findings.md @@ -0,0 +1,71 @@ +--- +type: research_findings +project: firefox-port +slug: firefox-extension-port +created: 2026-05-29T19:06:19Z +updated: 2026-05-29T19:10:19Z +--- + +# Findings: Firefox Extension Port Research + +## Source References + +- `extension/manifest.json`: current Manifest V3 Chrome extension declaration. +- `extension/background/background.js`: programmatic script injection, local API bridge, screenshot capture, responsive viewport override, and CDP recording implementation. +- `extension/content/content.js` and `extension/content/modules/*`: content-script messaging, toolbar/sidebar UI, annotation, bug report, performance, design, and inspiration flows. +- `.project/context/tech-context.md`: repo context for extension/server architecture and constraints. +- `npx --yes web-ext lint --source-dir extension --output json`: Firefox tooling baseline; returned 2 errors and 43 warnings. +- MDN Chrome incompatibilities: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities +- MDN background manifest key: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/background +- MDN scripting API: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting +- MDN scripting execution worlds: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld +- MDN content scripts: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts +- MDN tabs.captureVisibleTab: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/captureVisibleTab +- MDN webRequest API: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest +- MDN host_permissions: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/host_permissions +- MDN browser_specific_settings: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings +- Firefox Extension Workshop web-ext guide: https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext/ +- Firefox Extension Workshop signing overview: https://extensionworkshop.com/documentation/publish/signing-and-distribution-overview/ + +## Observations + +- The current manifest is not Firefox-ready. `web-ext lint` reports `BACKGROUND_SERVICE_WORKER_NOFALLBACK` because the current MV3 manifest only declares `background.service_worker`; Firefox requires or expects a `background.scripts` fallback for compatibility. It also reports `ADDON_ID_REQUIRED` because MV3 signing requires a Gecko add-on ID. +- Firefox supports the `chrome` namespace for compatibility and also supports promise-based APIs, so the current `await chrome.*` style is not the first blocker. A cross-browser wrapper is still recommended to make capability detection explicit. +- Programmatic injection is viable. Pointa uses `chrome.scripting.executeScript` and `chrome.scripting.insertCSS` in `extension/background/background.js`; MDN documents the `scripting` API in Firefox with the same permission model around `scripting`, host permissions, and `activeTab`. +- The main non-portable capability is `chrome.debugger`. Firefox documents Chrome's debugger API as not implemented, and `web-ext lint` flags every Pointa use of `debugger.attach`, `debugger.sendCommand`, `debugger.detach`, `debugger.onEvent`, and `debugger.onDetach`. +- Pointa uses `chrome.debugger` for three product capabilities: CDP screenshot capture when viewport emulation is active, responsive viewport emulation through `Emulation.setDeviceMetricsOverride`, and bug/performance recording through CDP `Network`, `Log`, and `Runtime` domains. +- Normal visible-tab screenshot capture can remain. Pointa already falls back to `chrome.tabs.captureVisibleTab` outside viewport override paths, and MDN documents `tabs.captureVisibleTab` in Firefox with `activeTab` or broad host permission requirements. +- Firefox content scripts have a different isolation model, including Xray behavior. For page-console instrumentation, a Firefox implementation should use a `scripting.executeScript` `MAIN` world injection or `userScripts` after explicit permission, not assume content scripts can directly observe page globals. +- Network timeline parity can likely be approximated with `webRequest` observation for permitted hosts. MDN requires the `webRequest` API permission plus host permissions for observed request hosts; subresources require permission for both the page and resource host. +- Firefox MV3 install prompts now display host permissions from `host_permissions` and `content_scripts` in Firefox 127+. Pointa's local-only host permissions are a good baseline, but onboarding should explain why localhost access is needed and possibly add HTTPS local patterns if desired. +- Firefox packaging/review has additional manifest requirements: MV3 add-on ID in `browser_specific_settings.gecko.id`; new Firefox submissions must declare `browser_specific_settings.gecko.data_collection_permissions`; signed add-ons are required for release/beta Firefox distribution through AMO or self-distribution signing. +- The current source has many `innerHTML` linter warnings. Existing docs say user input is generally escaped, but AMO review risk remains. A Firefox port should audit these warnings before submission. + +## Options Considered + +| Option | Pros | Cons | Decision | +| --- | --- | --- | --- | +| Single manifest shared by Chrome and Firefox | Minimal packaging complexity | Conflicting background requirements, Gecko ID/data consent keys, and invalid Firefox `debugger` permission make this brittle | Reject for initial port | +| Generated browser-specific manifests with shared JS/CSS/assets | Keeps one source tree while letting Chrome and Firefox differ where required | Requires build script and CI lint matrix | Recommended | +| Firefox MVP with CDP-dependent features disabled or degraded | Fastest path to a usable Firefox add-on for annotations, MCP, sidebar/toolbar, normal screenshots, and local server workflows | Bug/performance timelines and responsive capture are not parity-complete | Recommended first milestone | +| Full parity before release | Best product completeness | Requires redesigning CDP features without `chrome.debugger`; high uncertainty | Defer until MVP validates | +| Use Firefox `webRequest` plus main-world script instrumentation for issue timelines | Replaces some CDP behavior with supported APIs | More permissions and less complete than CDP; needs careful privacy review | Prototype in probe | +| Use native messaging for deep parity with a local helper | Could theoretically expose richer local capabilities | Heavy install burden, separate host manifests, review complexity, and poor fit for simple browser-store install | Reject unless MVP proves impossible | + +## Fold-Forward Candidates + +| Finding | Target Artifact | Proposed Change | +| --- | --- | --- | +| Firefox port is not a manifest-only change because `chrome.debugger` is unsupported. | `spec.md`, `plan.md`, `decisions.md` | Add CDP replacement/degradation as required architecture work. | +| Browser-specific manifest generation is required. | `plan.md`, `decisions.md` | Add packaging workstream and decision for generated Firefox manifest. | +| `web-ext lint` gives an actionable gate. | `plan.md` | Add `web-ext lint --source-dir dist/firefox` to validation. | +| Add-on ID and data collection manifest keys are required for Firefox submission. | `spec.md`, `plan.md` | Add release requirements for `browser_specific_settings.gecko`. | +| Core annotation and MCP workflows look portable. | `spec.md` | Define MVP scope around annotation/sidebar/toolbar/local server/normal screenshots. | + +## Open Questions + +- What minimum Firefox version should Pointa target: current desktop only, Firefox ESR, or desktop plus Android? +- Should Firefox launch as an MVP with degraded bug/performance capture, or wait for near-parity with Chrome? +- Should the Firefox package remain AMO-listed, self-distributed/unlisted, or both? +- Should the Firefox manifest add HTTPS localhost host permissions, or keep the current HTTP-only local host scope for a smaller permission story? +- How much of the `innerHTML` linter warning set must be refactored before AMO review, versus documented as sanitized templates? diff --git a/.project/projects/firefox-port/research/firefox-extension-port/progress.md b/.project/projects/firefox-port/research/firefox-extension-port/progress.md new file mode 100644 index 0000000..d2f80f3 --- /dev/null +++ b/.project/projects/firefox-port/research/firefox-extension-port/progress.md @@ -0,0 +1,34 @@ +--- +type: research_progress +project: firefox-port +slug: firefox-extension-port +created: 2026-05-29T19:06:19Z +updated: 2026-05-29T19:10:19Z +--- + +# Progress: Firefox Extension Port Research + +## 2026-05-29T19:06:19Z + +- Opened research intake for project `firefox-port`. +- Primary question: What technical, product, and release changes are required to port the Pointa Chrome extension to Firefox while preserving local-first MCP workflows? + +## 2026-05-29T19:10:19Z + +- Audited current extension manifest, background script, and content script modules. +- Checked Mozilla documentation for MV3 background scripts, API namespace compatibility, scripting, content script isolation, screenshot capture, webRequest, host permissions, Gecko manifest settings, web-ext, and signing. +- Ran `npx --yes web-ext lint --source-dir extension --output json`; baseline result was 2 errors and 43 warnings. +- Folded durable findings into `spec.md`, `plan.md`, and `decisions.md`. + +## Validation Evidence + +- `npx --yes web-ext lint --source-dir extension --output json`: failed as expected against current Chrome package with 2 Firefox compatibility errors and 43 warnings. Errors: missing Firefox-compatible background scripts fallback, missing Gecko add-on ID. Key warnings: invalid Firefox `debugger` permission, missing Gecko data collection permissions, unsupported `chrome.debugger` API uses, and unsafe `innerHTML` assignments. +- `delano research firefox-port firefox-extension-port --title "Firefox Extension Port Research" --question "What technical, product, and release changes are required to port the Pointa Chrome extension to Firefox while preserving local-first MCP workflows?" --owner team --json`: created intake and passed validation. + +## Handoff Summary + +- Changed: created Firefox port Delano project and research intake, investigated compatibility, recorded findings, and folded the plan into canonical project artifacts. +- Evidence: local source audit, Mozilla documentation, `web-ext lint` baseline, and Delano validation at intake creation. +- Blockers: no direct Firefox equivalent for `chrome.debugger`; full bug/performance timeline and responsive capture parity require architecture work. +- Lease state: no lease acquired. +- Next safe action: implement a Firefox manifest/build probe and disable or replace CDP-dependent paths behind browser capability checks. diff --git a/.project/projects/firefox-port/research/firefox-extension-port/task_plan.md b/.project/projects/firefox-port/research/firefox-extension-port/task_plan.md new file mode 100644 index 0000000..7448a1d --- /dev/null +++ b/.project/projects/firefox-port/research/firefox-extension-port/task_plan.md @@ -0,0 +1,59 @@ +--- +type: research_intake +project: firefox-port +slug: firefox-extension-port +owner: team +status: opened +created: 2026-05-29T19:06:19Z +updated: 2026-05-29T19:10:19Z +--- + +# Research Plan: Firefox Extension Port Research + +## Goal + +Answer the research question and fold durable conclusions into canonical Delano project artifacts. + +## Primary Question + +What technical, product, and release changes are required to port the Pointa Chrome extension to Firefox while preserving local-first MCP workflows? + +## Scope + +### In Scope + +- Gather relevant evidence. +- Capture findings and decisions. +- Identify changes needed in `spec.md`, `plan.md`, `decisions.md`, workstreams, tasks, or updates. + +### Out of Scope + +- Marking delivery tasks done from research alone. +- External sync writes without normal Delano approval semantics. +- Storing secrets, credentials, or private machine paths. + +## Current Phase + +Folded forward + +## Phases + +- [x] Open research intake +- [x] Investigate sources and options +- [x] Summarize findings +- [x] Fold forward into canonical project artifacts or explicitly close as no-action + +## Decisions Made + +| Decision | Rationale | +| --- | --- | +| Use a Firefox-specific generated manifest/package, not a manual fork of all extension code. | Firefox needs `background.scripts`, `browser_specific_settings.gecko`, and no `debugger` permission, while Chrome still needs `background.service_worker`. | +| Treat Chrome DevTools Protocol features as a separate compatibility workstream. | Firefox does not implement Chrome's `debugger` API, and Pointa currently uses it for responsive viewport emulation plus network/console CDP recording. | +| Make a Firefox MVP before parity work. | Core annotation, toolbar/sidebar, local server, MCP, storage, and normal screenshot flows look portable; CDP-dependent bug/performance fidelity needs redesign. | +| Use `web-ext lint` as the first automated Firefox gate. | It produced concrete baseline errors and warnings against the current `extension/` source. | + +## Blockers + +| Blocker | Owner | Check-back | +| --- | --- | --- | +| Full parity for responsive capture and CDP network/console timelines has no direct Firefox API equivalent. | team | During architecture probe | diff --git a/.project/projects/firefox-port/spec.md b/.project/projects/firefox-port/spec.md new file mode 100644 index 0000000..1b4d3b2 --- /dev/null +++ b/.project/projects/firefox-port/spec.md @@ -0,0 +1,210 @@ +--- +name: Firefox Extension Port +slug: firefox-port +owner: team +status: complete +created: 2026-05-29T19:06:15Z +updated: 2026-05-29T21:48:19Z +outcome: Build the most complete practical Firefox version of Pointa while preserving the local-first annotation, screenshot, and evidence-capture workflow. +uncertainty: high +probe_required: true +probe_status: completed +--- + +# Spec: Firefox Extension Port + +## Executive Summary + +Porting Pointa to Firefox is viable for the core local-first workflow, but it is +not a manifest-only port. The Chrome extension depends on Manifest V3, +programmatic content-script injection, the local `pointa-server` API on port +4242, and Chrome DevTools Protocol access through `chrome.debugger`. + +Firefox can support the core annotation, sidebar/toolbar, local server, MCP, +element-linked screenshots, and much of the issue evidence workflow. The high +risk area is CDP-dependent functionality: responsive viewport emulation and +bug/performance timeline capture using Network, Log, and Runtime debugger +domains. The Firefox build should replace those paths with supported browser +APIs where possible, including page instrumentation, `webRequest`, visible-tab +screenshot capture, and the existing backend-log bridge. + +## Problem and Users + +Firefox users should be able to use Pointa on localhost projects without +switching to Chromium. Maintainers need a port strategy that preserves one shared +extension codebase where possible while allowing browser-specific packaging, +permissions, and feature degradation. + +## Outcome and Success Metrics + +- A Firefox package can be linted with `web-ext lint` without errors. +- The Firefox add-on can be loaded with `web-ext run` and connect to + `pointa-server` on `http://127.0.0.1:4242`. +- Core flows work on a localhost page: open Pointa, inject UI, create/read/update + annotations, keep annotations linked to the intended element, attach + screenshots, and expose annotation image data to MCP. +- Issue-report flows capture the richest Firefox-supported evidence set: + console logs, errors, unhandled rejections, network metadata/failures, backend + logs, screenshots, and user interaction context where feasible. +- CDP-dependent Chrome-only features are either replaced with supported Firefox + APIs or clearly disabled with user-facing copy. +- Firefox release path is documented, including Gecko ID, data collection + declaration, signing, and AMO or self-distribution choice. + +## User Stories +- US-001: As a Firefox user, I want to annotate localhost UI with Pointa, so that + I can feed precise UI feedback to my AI coding agent without using Chromium. +- US-002: As a maintainer, I want a generated Firefox package that shares most + source files with Chrome, so that browser-specific differences do not create a + long-lived manual fork. +- US-003: As a maintainer, I want Chrome-only debugger features guarded by + capability checks, so that Firefox can run supported flows without runtime + failures. +- US-004: As a reviewer or release operator, I want Firefox-specific permissions + and data collection declarations to be explicit, so that AMO review risk is + visible before submission. +- US-005: As a Firefox user reporting a bug, I want Pointa to attach screenshots, + console logs, page errors, network failures, and backend logs when available, + so that my AI coding agent gets actionable evidence instead of a vague report. + +## Acceptance Scenarios +- AC-001: Given a Firefox build is generated, when `web-ext lint` runs against + that build, then it reports no errors. +- AC-002: Given `pointa-server` is running and a localhost page is open in + Firefox, when the user clicks the Pointa toolbar button, then the sidebar or + toolbar injects and can create an annotation. +- AC-003: Given a Firefox user captures an annotation screenshot, when no + responsive viewport override is active, then `tabs.captureVisibleTab` captures + the visible tab image successfully. +- AC-004: Given a Firefox user opens a CDP-only feature, when no Firefox + replacement exists, then the feature is disabled or degraded without throwing + extension errors. +- AC-005: Given a packaged Firefox add-on is prepared for distribution, when the + manifest is inspected, then it includes a Gecko add-on ID, compatible + background declaration, data collection declaration, and no unsupported + `debugger` permission. +- AC-006: Given Firefox issue recording is active, when the page emits console + messages, runtime errors, failed requests, or backend logs, then supported + events are attached to the saved issue timeline with timestamps and source + labels. + +## Scope +### In Scope +- Firefox desktop port of the extension. +- Generated or transformed Firefox manifest/package. +- Cross-browser API capability checks where needed. +- Replacement or graceful degradation of debugger-dependent features. +- Firefox-supported console/error/network/backend evidence capture. +- Strong annotation-to-element anchoring and screenshot attachment behavior. +- Firefox local-server connection and MCP workflow verification. +- Firefox development, linting, packaging, and signing plan. + +### Out of Scope +- Replacing the Node `pointa-server` architecture. +- Rewriting the extension with a framework or bundler unless required by the + packaging probe. +- Firefox for Android support until desktop Firefox MVP is validated. +- Chrome Web Store submission cleanup except where it overlaps AMO review risk. + +## Functional Requirements + +- Generate a Firefox-compatible extension source directory or package from the + shared `extension/` source. +- Firefox manifest must use a supported background declaration and include + `browser_specific_settings.gecko.id`. +- Firefox manifest must remove unsupported `debugger` permission. +- Extension runtime must not call `chrome.debugger` unless the API exists. +- Normal screenshot capture must use `tabs.captureVisibleTab`. +- Bug/performance capture must use supported fallbacks such as content-script + instrumentation and `webRequest`, or be marked unavailable in Firefox. +- Firefox evidence capture must include console methods, page errors, unhandled + promise rejections, failed network requests or request metadata where + permitted, backend logs from `pointa-server dev`, and screenshots where + capture permissions allow. +- Annotation element matching must preserve selector, stable attributes, text + sample, geometry, parent-chain, URL, and fallback matching data. +- Content scripts must continue to communicate with the background script and + local server API. + +## Non-Functional Requirements + +- Preserve local-first privacy: no new cloud dependency for Firefox. +- Keep the Chrome extension behavior intact. +- Keep browser-specific differences explicit in build or manifest generation. +- Keep permission prompts justified and explain localhost access plus Firefox + all-site screenshot access in onboarding or listing copy. +- Maintain AMO review readiness by addressing required manifest fields and + high-risk linter warnings. + +## Assumptions +- Firefox desktop is the first target. +- Pointa can ship Firefox with a richer supported evidence model even if exact + Chrome CDP parity remains unavailable. +- The existing local server CORS behavior remains sufficient for Firefox + localhost content-script and background fetches. + +## Needs Clarification +- Minimum Firefox version target. +- Distribution target: listed AMO, self-distributed signed XPI, or both. +- Whether Firefox for Android is required in the first release. +- Whether Firefox issue evidence must reach near-parity before public release or + can ship as a clearly documented supported subset. + +## Hypotheses and Unknowns + +- Hypothesis: core annotation and MCP workflows can run with manifest/build + changes and limited API compatibility shims. +- Unknown: whether Firefox AMO review will require refactoring all linter-reported + `innerHTML` warnings before approval. +- Unknown: how much CDP timeline fidelity can be recovered with `webRequest`, + main-world instrumentation, and backend-log capture. + +## Touchpoints to Exercise + +- `extension/manifest.json` transformation or generated Firefox manifest. +- `extension/background/background.js` injection, screenshot, API bridge, and + debugger-dependent methods. +- `extension/content/content.js` and modules for annotation, toolbar/sidebar, + bug/performance, design, and inspiration flows. +- `annotations-server/lib/server.js` CORS, health, annotation, bug report, and + screenshot endpoints. +- `web-ext lint`, `web-ext run`, and signed package workflow. + +## Probe Findings + +- `web-ext lint --source-dir extension --output json` reports two current errors: + Firefox-compatible background fallback is missing, and a Gecko add-on ID is + required for MV3. +- The same lint run flags `debugger` permission and all `chrome.debugger` calls + as unsupported by Firefox. +- Mozilla docs confirm Firefox supports `scripting` and `tabs.captureVisibleTab`, + but not Chrome's `debugger` API. + +## Footguns Discovered + +- Firefox MV3 background behavior differs from Chrome; relying only on + `background.service_worker` fails Firefox lint. +- Host permissions in Firefox install prompts need a clear story, especially if + adding broader localhost HTTPS patterns. +- Page-global instrumentation must account for Firefox content-script isolation. +- Firefox signing requires a stable add-on ID and AMO/self-distribution process. + +## Remaining Unknowns + +- Target minimum Firefox version and desktop-only versus Android scope. +- Final decision on MVP degradation versus full parity. +- AMO review tolerance for current dynamic `innerHTML` UI construction. + +## Dependencies + +- Firefox desktop and `web-ext`. +- Existing `pointa-server` package and local port 4242 workflow. +- Mozilla AMO account and signing credentials when packaging for distribution. +- Product decision on release channel and parity bar. + +## Approval Notes + +Approved planning conclusion: proceed with a full Firefox build plan that starts +with package and runtime compatibility, then builds core annotation parity, +element-linked screenshots, Firefox-supported evidence capture, QA, and release +readiness. diff --git a/.project/projects/firefox-port/tasks/T-001-add-firefox-build-target-and-manifest-generator.md b/.project/projects/firefox-port/tasks/T-001-add-firefox-build-target-and-manifest-generator.md new file mode 100644 index 0000000..ac72085 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-001-add-firefox-build-target-and-manifest-generator.md @@ -0,0 +1,49 @@ +--- +id: T-001 +name: Add Firefox build target and manifest generator +status: done +workstream: WS-001 +created: 2026-05-29T19:26:26Z +updated: 2026-05-29T19:32:05Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [] +conflicts_with: [] +parallel: true +priority: high +estimate: M +story_id: US-002 +acceptance_criteria_ids: [AC-001, AC-005] +--- + +# Task: Add Firefox build target and manifest generator + +## Description + +Create a repeatable build path that copies shared extension assets into a Firefox-specific output and writes a Firefox-compatible manifest. + +## Acceptance Criteria + +- [x] A command generates dist/firefox from the shared extension source. +- [x] The Firefox manifest includes Gecko browser_specific_settings, data collection declaration, and compatible background configuration. +- [x] The Firefox manifest omits unsupported debugger permission while leaving the Chrome manifest unchanged. + +## Traceability +- Story: US-002 +- Acceptance criteria: AC-001, AC-005 + +## Technical Notes + +## Definition of Done +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated + +## Evidence Log + +- 2026-05-29T19:32:05Z: Implemented scripts/build-firefox-extension.js; command node scripts/build-firefox-extension.js generates dist/firefox; generated Firefox manifest includes browser_specific_settings.gecko, data_collection_permissions, background.scripts, and omits debugger while extension/manifest.json remains unchanged; npx --yes web-ext lint --source-dir dist/firefox --output json exits with 0 errors. + +- 2026-05-29T19:30:32Z: Begin Firefox package build target implementation +- 2026-05-29T19:26:26Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-002-add-web-ext-tooling-and-firefox-scripts.md b/.project/projects/firefox-port/tasks/T-002-add-web-ext-tooling-and-firefox-scripts.md new file mode 100644 index 0000000..8757110 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-002-add-web-ext-tooling-and-firefox-scripts.md @@ -0,0 +1,51 @@ +--- +id: T-002 +name: Add web-ext tooling and Firefox scripts +status: done +workstream: WS-001 +created: 2026-05-29T19:26:26Z +updated: 2026-05-29T19:34:09Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-001] +conflicts_with: [] +parallel: true +priority: high +estimate: S +story_id: US-002 +acceptance_criteria_ids: [AC-001] +--- + +# Task: Add web-ext tooling and Firefox scripts + +## Description + +Add package scripts and tool configuration for Firefox linting, local running, and packaging. + +## Acceptance Criteria + +- [x] Repository scripts expose Firefox build, lint, run, and package commands. +- [x] web-ext runs against the generated Firefox output directory. +- [x] Tooling changes are documented in the project plan or developer docs. + +## Traceability +- Story: US-002 +- Acceptance criteria: AC-001 + +## Technical Notes + +## Definition of Done +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated + +## Evidence Log + +- 2026-05-29T19:34:09Z: Added root scripts firefox:build, firefox:lint, firefox:run, and firefox:package; documented Firefox development workflow in docs/DEVELOPMENT.md; npm run firefox:build succeeds; npm run firefox:lint runs web-ext against dist/firefox and exits with 0 errors; npm run firefox:package creates dist/firefox-artifacts/pointa-1.3.6.zip. + +- 2026-05-29T19:33:15Z: Begin Firefox web-ext tooling scripts and documentation + +- 2026-05-29T19:32:42Z: Dependency T-001 is done; Firefox tooling task is ready +- 2026-05-29T19:26:26Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-003-resolve-firefox-manifest-lint-errors.md b/.project/projects/firefox-port/tasks/T-003-resolve-firefox-manifest-lint-errors.md new file mode 100644 index 0000000..9081fa9 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-003-resolve-firefox-manifest-lint-errors.md @@ -0,0 +1,51 @@ +--- +id: T-003 +name: Resolve Firefox manifest lint errors +status: done +workstream: WS-001 +created: 2026-05-29T19:26:26Z +updated: 2026-05-29T19:35:08Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-001, T-002] +conflicts_with: [] +parallel: true +priority: high +estimate: M +story_id: US-002 +acceptance_criteria_ids: [AC-001, AC-005] +--- + +# Task: Resolve Firefox manifest lint errors + +## Description + +Drive the generated Firefox package to zero web-ext lint errors and document remaining warnings. + +## Acceptance Criteria + +- [x] web-ext lint exits with zero errors for the Firefox output. +- [x] Any remaining web-ext warnings are captured with owner and disposition. +- [x] The baseline no longer reports missing background fallback or missing Gecko ID. + +## Traceability +- Story: US-002 +- Acceptance criteria: AC-001, AC-005 + +## Technical Notes + +## Definition of Done +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated + +## Evidence Log + +- 2026-05-29T19:35:08Z: npm run firefox:lint exits with 0 errors against dist/firefox; generated Firefox manifest no longer reports missing background fallback, missing Gecko ID, or invalid debugger permission; remaining warning classes are documented with owner/disposition in docs/FIREFOX_PORT.md. + +- 2026-05-29T19:34:38Z: Document and verify Firefox lint baseline after build tooling + +- 2026-05-29T19:34:34Z: Dependencies T-001 and T-002 are done; manifest lint cleanup task is ready +- 2026-05-29T19:26:26Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-004-document-firefox-package-architecture.md b/.project/projects/firefox-port/tasks/T-004-document-firefox-package-architecture.md new file mode 100644 index 0000000..5f99b7a --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-004-document-firefox-package-architecture.md @@ -0,0 +1,51 @@ +--- +id: T-004 +name: Document Firefox package architecture +status: done +workstream: WS-001 +created: 2026-05-29T19:26:26Z +updated: 2026-05-29T19:35:19Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-001, T-002] +conflicts_with: [] +parallel: true +priority: medium +estimate: S +story_id: US-002 +acceptance_criteria_ids: [AC-005] +--- + +# Task: Document Firefox package architecture + +## Description + +Document how the Chrome and Firefox extension packages are generated and where browser-specific differences belong. + +## Acceptance Criteria + +- [x] Documentation names the source directory, generated Firefox output, and release artifact path. +- [x] Documentation states that Chrome manifest behavior must remain untouched unless Chrome validation is run. +- [x] Documentation explains how Firefox manifest fields are sourced and updated. + +## Traceability +- Story: US-002 +- Acceptance criteria: AC-005 + +## Technical Notes + +## Definition of Done +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated + +## Evidence Log + +- 2026-05-29T19:35:19Z: Added docs/FIREFOX_PORT.md documenting shared source, Chrome manifest, Firefox build script, dist/firefox output, dist/firefox-artifacts package path, manifest transformation behavior, and warning baseline; docs/DEVELOPMENT.md links to the Firefox port doc. + +- 2026-05-29T19:35:11Z: Document Firefox package architecture after build tooling + +- 2026-05-29T19:34:34Z: Dependencies T-001 and T-002 are done; package architecture documentation task is ready +- 2026-05-29T19:26:26Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-005-add-cross-browser-api-and-capability-helper.md b/.project/projects/firefox-port/tasks/T-005-add-cross-browser-api-and-capability-helper.md new file mode 100644 index 0000000..fedd9c0 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-005-add-cross-browser-api-and-capability-helper.md @@ -0,0 +1,51 @@ +--- +id: T-005 +name: Add cross-browser API and capability helper +status: done +workstream: WS-002 +created: 2026-05-29T19:26:26Z +updated: 2026-05-29T19:37:38Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-001] +conflicts_with: [] +parallel: true +priority: high +estimate: M +story_id: US-003 +acceptance_criteria_ids: [AC-004] +--- + +# Task: Add cross-browser API and capability helper + +## Description + +Create a small runtime helper for browser namespace access and capability checks for debugger, scripting, tabs, storage, and webRequest. + +## Acceptance Criteria + +- [x] Shared extension code can query capabilities without directly probing unsupported APIs throughout the codebase. +- [x] The helper works when only chrome namespace is present and when browser namespace is present. +- [x] Debugger availability is exposed as an explicit false capability in Firefox. + +## Traceability +- Story: US-003 +- Acceptance criteria: AC-004 + +## Technical Notes + +## Definition of Done +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated + +## Evidence Log + +- 2026-05-29T19:37:38Z: Added extension/common/browser-compat.js global PointaBrowser helper; background loads helper, exposes capabilities via messages, injects helper before content modules, and gates debugger/CDP paths; node --check passes for helper/background; capability VM smoke passes for chrome debugger=true and firefox debugger=false; npm run firefox:lint exits with 0 errors. + +- 2026-05-29T19:37:19Z: Integrate cross-browser compatibility helper implementation from worker + +- 2026-05-29T19:32:42Z: Dependency T-001 is done; runtime compatibility helper task is ready +- 2026-05-29T19:26:26Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-006-adapt-background-lifecycle-and-injection-for-firefox.md b/.project/projects/firefox-port/tasks/T-006-adapt-background-lifecycle-and-injection-for-firefox.md new file mode 100644 index 0000000..7a899b4 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-006-adapt-background-lifecycle-and-injection-for-firefox.md @@ -0,0 +1,51 @@ +--- +id: T-006 +name: Adapt background lifecycle and injection for Firefox +status: done +workstream: WS-002 +created: 2026-05-29T19:26:26Z +updated: 2026-05-29T19:50:52Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-005] +conflicts_with: [] +parallel: true +priority: high +estimate: M +story_id: US-001 +acceptance_criteria_ids: [AC-002] +--- + +# Task: Adapt background lifecycle and injection for Firefox + +## Description + +Make background startup, action handling, content CSS insertion, and ordered module injection work in Firefox. + +## Acceptance Criteria + +- [x] Opening the Pointa action in Firefox injects the content CSS and modules on a supported localhost page. +- [x] Repeated action clicks do not duplicate content scripts or break existing injection locks. +- [x] Unsupported pages fail gracefully without noisy user-facing errors. + +## Traceability +- Story: US-001 +- Acceptance criteria: AC-002 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T19:50:52Z: Adapted action click and ordered injection for Firefox: unsupported page schemes are skipped quietly, content CSS/modules inject in fixed order, one in-flight injection per tab is reused, per-file markers make retries idempotent, and Firefox background runtime is documented. Verified node --check on background/common helpers, npm run firefox:lint exits 0, and npm run firefox:package produces dist/firefox-artifacts/pointa-1.3.6.zip. + +- 2026-05-29T19:46:57Z: Adapting Firefox background action handling and ordered content injection + +- 2026-05-29T19:38:17Z: Dependency T-005 is done; Firefox background/injection task is ready +- 2026-05-29T19:26:26Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-007-centralize-local-server-url-and-health-handling.md b/.project/projects/firefox-port/tasks/T-007-centralize-local-server-url-and-health-handling.md new file mode 100644 index 0000000..27c7f52 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-007-centralize-local-server-url-and-health-handling.md @@ -0,0 +1,51 @@ +--- +id: T-007 +name: Centralize local server URL and health handling +status: done +workstream: WS-002 +created: 2026-05-29T19:26:26Z +updated: 2026-05-29T19:45:49Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-005] +conflicts_with: [] +parallel: true +priority: medium +estimate: S +story_id: US-001 +acceptance_criteria_ids: [AC-002] +--- + +# Task: Centralize local server URL and health handling + +## Description + +Remove scattered local server URL assumptions by routing health and API calls through shared configuration or helper methods. + +## Acceptance Criteria + +- [x] Firefox and Chrome use the same canonical local server URL source. +- [x] Server offline state is reported consistently in popup/sidebar flows. +- [x] No new cloud or remote dependency is introduced. + +## Traceability +- Story: US-001 +- Acceptance criteria: AC-002 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T19:45:49Z: Added extension/common/browser-compat.js local-server helpers and routed background/content health, API fallback, toolbar/sidebar, upload/image, onboarding MCP URL, and Pointa-owned request filtering through them. Verified node --check on touched JS, npm run firefox:lint exits 0, and hardcoded URL scan leaves only the canonical helper literal. + +- 2026-05-29T19:45:42Z: Centralizing local server URL and health handling across background/content surfaces + +- 2026-05-29T19:38:17Z: Dependency T-005 is done; local server URL centralization task is ready +- 2026-05-29T19:26:26Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-008-gate-unsupported-debugger-and-cdp-paths.md b/.project/projects/firefox-port/tasks/T-008-gate-unsupported-debugger-and-cdp-paths.md new file mode 100644 index 0000000..343dc6b --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-008-gate-unsupported-debugger-and-cdp-paths.md @@ -0,0 +1,51 @@ +--- +id: T-008 +name: Gate unsupported debugger and CDP paths +status: done +workstream: WS-002 +created: 2026-05-29T19:26:27Z +updated: 2026-05-29T19:39:04Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-005] +conflicts_with: [] +parallel: true +priority: high +estimate: M +story_id: US-003 +acceptance_criteria_ids: [AC-004] +--- + +# Task: Gate unsupported debugger and CDP paths + +## Description + +Protect all viewport emulation, CDP screenshot, network, log, and runtime debugger paths behind capability checks. + +## Acceptance Criteria + +- [x] Firefox runtime never calls chrome.debugger methods. +- [x] Chrome debugger behavior remains available when capability checks pass. +- [x] Firefox receives structured unavailable/degraded responses for CDP-only operations. + +## Traceability +- Story: US-003 +- Acceptance criteria: AC-004 + +## Technical Notes + +## Definition of Done +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated + +## Evidence Log + +- 2026-05-29T19:39:04Z: All chrome.debugger call sites in extension/background/background.js are protected by hasCapability('debugger') entry-point guards; setupCDPEventListener is only registered when debugger is available; Firefox setViewport/startCDPRecording return structured error responses through message handlers; stopCDPRecording returns empty event arrays without debugger; Chrome paths remain intact when capability is true; node --check and npm run firefox:lint pass with 0 errors. + +- 2026-05-29T19:38:28Z: Verify and complete debugger/CDP capability gates + +- 2026-05-29T19:38:17Z: Dependency T-005 is done; debugger/CDP guard task is ready +- 2026-05-29T19:26:27Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-009-verify-firefox-permissions-and-local-host-scope.md b/.project/projects/firefox-port/tasks/T-009-verify-firefox-permissions-and-local-host-scope.md new file mode 100644 index 0000000..40a5f5c --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-009-verify-firefox-permissions-and-local-host-scope.md @@ -0,0 +1,53 @@ +--- +id: T-009 +name: Verify Firefox permissions and local host scope +status: done +workstream: WS-002 +created: 2026-05-29T19:26:27Z +updated: 2026-05-29T21:36:41Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-001, T-006] +conflicts_with: [] +parallel: true +priority: medium +estimate: S +story_id: US-004 +acceptance_criteria_ids: [AC-005] +--- + +# Task: Verify Firefox permissions and local host scope + +## Description + +Finalize Firefox permissions and local host patterns needed for annotation, screenshots, server fetches, and evidence capture. + +## Acceptance Criteria + +- [x] Firefox manifest permissions are minimal for the MVP feature set. +- [x] Localhost host permissions are documented with rationale. +- [x] Permission choices are reflected in AMO/privacy planning notes. + +## Traceability +- Story: US-004 +- Acceptance criteria: AC-005 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T21:36:41Z: Permission scope updated after Zen screenshot smoke: Firefox artifact now keeps activeTab/storage/scripting API permissions, removes debugger/tabs, and adds host permission because MDN requires or activeTab for tabs.captureVisibleTab and persistent toolbar screenshots cannot depend on activeTab after navigation. Updated Firefox port, release, privacy, evidence, QA, and spec notes. + +- 2026-05-29T19:54:02Z: Firefox manifest generation now removes Chrome-only debugger and broad tabs permissions, leaving activeTab/storage/scripting plus local-development host permissions. Documented local host rationale and AMO permission notes in docs/FIREFOX_PORT.md and docs/FIREFOX_RELEASE.md. Verified node --check scripts/build-firefox-extension.js, npm run firefox:lint exits 0, and dist/firefox/manifest.json contains only activeTab/storage/scripting permissions. + +- 2026-05-29T19:53:19Z: Finalizing Firefox permission and local host scope now that background injection is implemented + +- 2026-05-29T19:51:28Z: Dependencies T-001 and T-006 are done; permission scope verification is unblocked +- 2026-05-29T19:26:27Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-010-port-annotation-crud-flow-in-firefox.md b/.project/projects/firefox-port/tasks/T-010-port-annotation-crud-flow-in-firefox.md new file mode 100644 index 0000000..e85231c --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-010-port-annotation-crud-flow-in-firefox.md @@ -0,0 +1,51 @@ +--- +id: T-010 +name: Port annotation CRUD flow in Firefox +status: done +workstream: WS-003 +created: 2026-05-29T19:26:27Z +updated: 2026-05-29T19:55:39Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-006, T-007] +conflicts_with: [] +parallel: true +priority: high +estimate: M +story_id: US-001 +acceptance_criteria_ids: [AC-002] +--- + +# Task: Port annotation CRUD flow in Firefox + +## Description + +Make annotation creation, loading, updating, deletion, status changes, and sidebar/toolbar display work in Firefox. + +## Acceptance Criteria + +- [x] A Firefox user can create an annotation on the demo localhost page. +- [x] Reloading the page shows the saved annotation in the correct page context. +- [x] Status update and delete actions persist through the local server API. + +## Traceability +- Story: US-001 +- Acceptance criteria: AC-002 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T19:55:39Z: Ported Firefox annotation CRUD with the tighter permission model by replacing tabs.query({ url }) badge refresh with tabs.query({}) plus permitted local URL filtering. Verified background save/update/delete/getAnnotations against a Firefox-style VM mock using local API fetches, node --check on background/content/badge manager, and npm run firefox:lint exits 0. + +- 2026-05-29T19:54:07Z: Validating and porting the Firefox annotation CRUD flow after runtime compatibility is complete + +- 2026-05-29T19:51:34Z: Dependencies T-006 and T-007 are done; Firefox annotation CRUD verification is unblocked +- 2026-05-29T19:26:27Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-011-strengthen-firefox-element-anchoring-and-fallback-matching.md b/.project/projects/firefox-port/tasks/T-011-strengthen-firefox-element-anchoring-and-fallback-matching.md new file mode 100644 index 0000000..f13c7a8 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-011-strengthen-firefox-element-anchoring-and-fallback-matching.md @@ -0,0 +1,51 @@ +--- +id: T-011 +name: Strengthen Firefox element anchoring and fallback matching +status: done +workstream: WS-003 +created: 2026-05-29T19:26:27Z +updated: 2026-05-29T20:00:48Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-010] +conflicts_with: [] +parallel: true +priority: high +estimate: M +story_id: US-001 +acceptance_criteria_ids: [AC-002] +--- + +# Task: Strengthen Firefox element anchoring and fallback matching + +## Description + +Ensure annotations remain linked to the intended element using selectors, stable attributes, text samples, geometry, and parent context. + +## Acceptance Criteria + +- [x] Saved annotations include enough element context to retry matching after DOM changes. +- [x] Fallback matching prefers stable id/data attributes before brittle nth-child selectors. +- [x] A changed demo DOM still resolves at least one intentionally shifted annotated element. + +## Traceability +- Story: US-001 +- Acceptance criteria: AC-002 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:00:48Z: Saved annotations now carry stable attributes, sibling indexes, parent context, text, and geometry; selector generation prioritizes id/data/ARIA/name/type/role/href/alt/title before class/position fallbacks and no longer emits synthetic data-text-content selectors; element matching tries stable attributes before text/class/position. Verified node --check on touched content modules, git diff --check, and npm run firefox:lint exits 0. + +- 2026-05-29T19:56:17Z: Strengthening element context and fallback matching for Firefox annotations + +- 2026-05-29T19:56:08Z: Dependency T-010 is done; Firefox element anchoring work is unblocked +- 2026-05-29T19:26:27Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-012-implement-firefox-visible-tab-and-element-screenshot-attachments.md b/.project/projects/firefox-port/tasks/T-012-implement-firefox-visible-tab-and-element-screenshot-attachments.md new file mode 100644 index 0000000..86774c2 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-012-implement-firefox-visible-tab-and-element-screenshot-attachments.md @@ -0,0 +1,53 @@ +--- +id: T-012 +name: Implement Firefox visible-tab and element screenshot attachments +status: done +workstream: WS-003 +created: 2026-05-29T19:26:27Z +updated: 2026-05-29T21:36:41Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-008, T-010] +conflicts_with: [] +parallel: true +priority: high +estimate: M +story_id: US-001 +acceptance_criteria_ids: [AC-003] +--- + +# Task: Implement Firefox visible-tab and element screenshot attachments + +## Description + +Use Firefox-supported visible tab capture and content-side cropping to attach screenshots to annotations or reports. + +## Acceptance Criteria + +- [x] Firefox can attach a visible-tab screenshot to an annotation or report. +- [x] Element-level screenshot crop uses captured image plus element geometry when available. +- [x] Screenshot failures return actionable errors without losing the annotation. + +## Traceability +- Story: US-001 +- Acceptance criteria: AC-003 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T21:36:41Z: Follow-up from regular-profile Zen smoke: screenshot attach failed because persistent in-page toolbar capture cannot rely on a stale activeTab grant. Firefox build now includes host permission for visible-tab capture, captureVisibleTab no longer passes undefined as windowId, and annotation screenshot UI surfaces structured background errors instead of a generic message. Verified node --check, firefox:lint, firefox:package, generated manifest, and local server health. + +- 2026-05-29T20:22:35Z: captureScreenshot now accepts sender tab/window context, uses Firefox-supported tabs.captureVisibleTab without tabs.get, preserves Chrome debugger capture only when available, and returns structured NO_ACTIVE_TAB/CAPTURE_VISIBLE_TAB_UNAVAILABLE/permission/restricted-page errors. Content-side element crop remains driven by captured image plus element geometry. node --check passed for background.js; npm run firefox:lint exits 0 with known warning baseline; docs/FIREFOX_EVIDENCE_CAPTURE.md documents structured screenshot failure behavior. + +- 2026-05-29T20:22:23Z: Screenshot worker completed implementation and parent review/validation passed. + +- 2026-05-29T20:06:57Z: Dependencies T-008 and T-010 are done; Firefox screenshot attachment work is unblocked +- 2026-05-29T19:26:27Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-013-verify-image-upload-storage-and-mcp-annotation-payloads.md b/.project/projects/firefox-port/tasks/T-013-verify-image-upload-storage-and-mcp-annotation-payloads.md new file mode 100644 index 0000000..4649bec --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-013-verify-image-upload-storage-and-mcp-annotation-payloads.md @@ -0,0 +1,49 @@ +--- +id: T-013 +name: Verify image upload storage and MCP annotation payloads +status: done +workstream: WS-003 +created: 2026-05-29T19:26:27Z +updated: 2026-05-29T20:25:17Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-012] +conflicts_with: [] +parallel: true +priority: medium +estimate: M +story_id: US-001 +acceptance_criteria_ids: [AC-002, AC-003] +--- + +# Task: Verify image upload storage and MCP annotation payloads + +## Description + +Confirm Firefox screenshot attachments are stored by pointa-server and readable through MCP annotation image tools. + +## Acceptance Criteria + +- [x] Uploaded Firefox screenshots are stored under existing server image paths. +- [x] MCP annotation data reports has_images and image paths correctly. +- [x] get_annotation_images returns the Firefox-created image data successfully. + +## Traceability +- Story: US-001 +- Acceptance criteria: AC-002, AC-003 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:25:17Z: Isolated server smoke with temporary HOME and POINTA_PORT uploaded a PNG through /api/upload-image, saved an annotation with reference_images, confirmed MCP read_annotations and read_annotation_by_id returned has_images=true/image_count=1/image_paths, and confirmed get_annotation_images returned data:image/png;base64 data. docs/FIREFOX_EVIDENCE_CAPTURE.md now records the image/MCP payload path. + +- 2026-05-29T20:23:00Z: T-012 is done; verifying screenshot upload/storage/MCP payload path. +- 2026-05-29T19:26:27Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-014-validate-design-and-inspiration-annotation-compatibility.md b/.project/projects/firefox-port/tasks/T-014-validate-design-and-inspiration-annotation-compatibility.md new file mode 100644 index 0000000..28ab87d --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-014-validate-design-and-inspiration-annotation-compatibility.md @@ -0,0 +1,49 @@ +--- +id: T-014 +name: Validate design and inspiration annotation compatibility +status: done +workstream: WS-003 +created: 2026-05-29T19:26:27Z +updated: 2026-05-29T20:26:48Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-010, T-012] +conflicts_with: [] +parallel: true +priority: medium +estimate: L +story_id: US-001 +acceptance_criteria_ids: [AC-002, AC-003] +--- + +# Task: Validate design and inspiration annotation compatibility + +## Description + +Exercise design mode and inspiration capture in Firefox and classify required fixes or supported/degraded behavior. + +## Acceptance Criteria + +- [x] Design-mode annotations can be created or are explicitly marked unsupported with rationale. +- [x] Inspiration screenshots and metadata work or have documented Firefox gaps. +- [x] Findings update the plan, tasks, or user-facing copy as needed. + +## Traceability +- Story: US-001 +- Acceptance criteria: AC-002, AC-003 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:26:48Z: Isolated server smoke saved/read a design-edit annotation through /api/annotations, saved/fetched an inspiration screenshot through /api/inspiration-screenshots, saved/read inspiration metadata through /api/inspirations, and confirmed responsive=false for Firefox-compatible visible capture. node --check passed for design-mode, design-editor-ui, and inspiration-mode. docs/FIREFOX_DESIGN_INSPIRATION_COMPAT.md documents support and Firefox gaps. + +- 2026-05-29T20:25:31Z: T-010 and T-012 are done; validating design and inspiration annotation compatibility. +- 2026-05-29T19:26:27Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-015-design-firefox-evidence-capture-model.md b/.project/projects/firefox-port/tasks/T-015-design-firefox-evidence-capture-model.md new file mode 100644 index 0000000..539a725 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-015-design-firefox-evidence-capture-model.md @@ -0,0 +1,51 @@ +--- +id: T-015 +name: Design Firefox evidence capture model +status: done +workstream: WS-004 +created: 2026-05-29T19:26:27Z +updated: 2026-05-29T19:39:40Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-008] +conflicts_with: [] +parallel: true +priority: high +estimate: M +story_id: US-003 +acceptance_criteria_ids: [AC-004, AC-006] +--- + +# Task: Design Firefox evidence capture model + +## Description + +Define the supported Firefox replacement for CDP issue timelines using page instrumentation, webRequest, and backend logs. + +## Acceptance Criteria + +- [x] Design document states which event types Firefox will capture. +- [x] Design document states which Chrome CDP events cannot be matched exactly. +- [x] Permission and privacy impacts are listed for each evidence source. + +## Traceability +- Story: US-003 +- Acceptance criteria: AC-004, AC-006 + +## Technical Notes + +## Definition of Done +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated + +## Evidence Log + +- 2026-05-29T19:39:40Z: Added docs/FIREFOX_EVIDENCE_CAPTURE.md covering Firefox event sources, console/error/rejection/network/backend/screenshot event types, CDP gaps, parity matrix, and permission/privacy impacts; linked it from docs/FIREFOX_PORT.md. + +- 2026-05-29T19:39:15Z: Design Firefox-supported replacement for CDP evidence capture + +- 2026-05-29T19:39:15Z: Dependency T-008 is done; Firefox evidence capture design task is ready +- 2026-05-29T19:26:27Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-016-inject-page-console-instrumentation-in-firefox.md b/.project/projects/firefox-port/tasks/T-016-inject-page-console-instrumentation-in-firefox.md new file mode 100644 index 0000000..6e431da --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-016-inject-page-console-instrumentation-in-firefox.md @@ -0,0 +1,51 @@ +--- +id: T-016 +name: Inject page console instrumentation in Firefox +status: done +workstream: WS-004 +created: 2026-05-29T19:26:27Z +updated: 2026-05-29T20:06:38Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-006, T-015] +conflicts_with: [] +parallel: true +priority: high +estimate: L +story_id: US-003 +acceptance_criteria_ids: [AC-004, AC-006] +--- + +# Task: Inject page console instrumentation in Firefox + +## Description + +Inject a Firefox-safe main-world script to observe console methods and forward events to the content script while recording. + +## Acceptance Criteria + +- [x] Recording captures console.log, console.warn, and console.error generated after instrumentation starts. +- [x] Events include timestamp, level, message, and page URL. +- [x] Instrumentation can be started and stopped without permanently modifying page behavior. + +## Traceability +- Story: US-003 +- Acceptance criteria: AC-004, AC-006 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:06:38Z: Added packaged MAIN-world console recorder injected via scripting.executeScript. It wraps console.log/warn/error only while recording, serializes arguments defensively, forwards timestamp/relativeTime/level/message/source URL/severity events, merges them with CDP console events, and restores original console methods on stop. Verified node --check on bug recorder/page console recorder/background, VM smoke for log/warn/error capture and restore, and npm run firefox:lint exits 0. + +- 2026-05-29T20:04:14Z: Adding Firefox MAIN-world console instrumentation after network fallback is in place + +- 2026-05-29T19:51:37Z: Dependencies T-006 and T-015 are done; Firefox console instrumentation is unblocked +- 2026-05-29T19:26:27Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-017-capture-page-errors-and-unhandled-rejections.md b/.project/projects/firefox-port/tasks/T-017-capture-page-errors-and-unhandled-rejections.md new file mode 100644 index 0000000..95f964a --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-017-capture-page-errors-and-unhandled-rejections.md @@ -0,0 +1,51 @@ +--- +id: T-017 +name: Capture page errors and unhandled rejections +status: done +workstream: WS-004 +created: 2026-05-29T19:26:27Z +updated: 2026-05-29T20:08:36Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-016] +conflicts_with: [] +parallel: true +priority: high +estimate: M +story_id: US-003 +acceptance_criteria_ids: [AC-004, AC-006] +--- + +# Task: Capture page errors and unhandled rejections + +## Description + +Record window error and unhandled promise rejection events into Pointa issue reports in Firefox. + +## Acceptance Criteria + +- [x] Runtime exceptions during recording appear in the issue timeline. +- [x] Unhandled promise rejections during recording appear in the issue timeline. +- [x] Captured events include source location when Firefox exposes it. + +## Traceability +- Story: US-003 +- Acceptance criteria: AC-004, AC-006 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:08:36Z: Extended packaged MAIN-world recorder to listen for runtime error and unhandledrejection events while recording, forwarding them as console-error timeline entries with subtypes page-error/unhandled-rejection and source location fields when available. Verified node --check on recorder/bug-recorder/background, VM smoke for page error and rejection capture, git diff --check, and npm run firefox:lint exits 0. + +- 2026-05-29T20:07:16Z: Extending Firefox page instrumentation to runtime exceptions and unhandled rejections + +- 2026-05-29T20:06:53Z: Dependency T-016 is done; Firefox page error capture is unblocked +- 2026-05-29T19:26:27Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-018-capture-firefox-network-metadata-and-failures.md b/.project/projects/firefox-port/tasks/T-018-capture-firefox-network-metadata-and-failures.md new file mode 100644 index 0000000..1b6a01e --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-018-capture-firefox-network-metadata-and-failures.md @@ -0,0 +1,51 @@ +--- +id: T-018 +name: Capture Firefox network metadata and failures +status: done +workstream: WS-004 +created: 2026-05-29T19:26:27Z +updated: 2026-05-29T20:04:01Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-015] +conflicts_with: [] +parallel: true +priority: high +estimate: L +story_id: US-003 +acceptance_criteria_ids: [AC-004, AC-006] +--- + +# Task: Capture Firefox network metadata and failures + +## Description + +Prototype Firefox network evidence using webRequest and/or page fetch/XHR instrumentation without CDP. + +## Acceptance Criteria + +- [x] Failed requests during recording appear in the issue timeline. +- [x] Captured request metadata includes URL, method, status or failure reason when available. +- [x] Response body capture is not promised unless a supported implementation exists. + +## Traceability +- Story: US-003 +- Acceptance criteria: AC-004, AC-006 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:04:01Z: Added Firefox-safe page network metadata fallback: a packaged MAIN-world recorder injected through scripting.executeScript wraps fetch/XMLHttpRequest during recording, buffers URL/method/status/error metadata, restores original APIs on stop, and merges with CDP network events with duplicate filtering. Response body capture is explicitly not promised. Verified node --check on bug recorder/page recorder/background, a VM smoke confirmed failed fetch capture and restore, and npm run firefox:lint exits 0. + +- 2026-05-29T20:03:52Z: Integrating Firefox network metadata fallback completed by worker + +- 2026-05-29T19:51:39Z: Dependency T-015 is done; Firefox network metadata capture is unblocked +- 2026-05-29T19:26:27Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-019-preserve-backend-log-capture-for-firefox-issue-reports.md b/.project/projects/firefox-port/tasks/T-019-preserve-backend-log-capture-for-firefox-issue-reports.md new file mode 100644 index 0000000..b9ee28c --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-019-preserve-backend-log-capture-for-firefox-issue-reports.md @@ -0,0 +1,51 @@ +--- +id: T-019 +name: Preserve backend log capture for Firefox issue reports +status: done +workstream: WS-004 +created: 2026-05-29T19:26:27Z +updated: 2026-05-29T20:09:44Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-007, T-017] +conflicts_with: [] +parallel: true +priority: medium +estimate: M +story_id: US-003 +acceptance_criteria_ids: [AC-004, AC-006] +--- + +# Task: Preserve backend log capture for Firefox issue reports + +## Description + +Ensure existing pointa-server dev backend log capture can be included in Firefox issue timelines. + +## Acceptance Criteria + +- [x] Firefox UI can show backend log capture status from the local server. +- [x] Starting and stopping backend log recording works from Firefox flows. +- [x] Backend log events are included in saved issue reports when available. + +## Traceability +- Story: US-003 +- Acceptance criteria: AC-004, AC-006 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:09:44Z: Verified Firefox uses the same local pointa-server backend log endpoints and existing UI flow: toolbar checks getBackendLogStatus, BugRecorder starts/stops backend log recording when enabled, and returned logs are transformed into backend-* timeline events. VM smoke confirmed status/start/stop endpoint use and captureStdout payload; node --check passed for background, bug recorder, and toolbar panels. + +- 2026-05-29T20:09:18Z: Verifying backend log capture path works under Firefox-compatible runtime + +- 2026-05-29T20:09:01Z: Dependencies T-007 and T-017 are done; backend log capture verification is unblocked +- 2026-05-29T19:26:27Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-020-map-firefox-evidence-parity-and-degraded-states.md b/.project/projects/firefox-port/tasks/T-020-map-firefox-evidence-parity-and-degraded-states.md new file mode 100644 index 0000000..3541728 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-020-map-firefox-evidence-parity-and-degraded-states.md @@ -0,0 +1,51 @@ +--- +id: T-020 +name: Map Firefox evidence parity and degraded states +status: done +workstream: WS-004 +created: 2026-05-29T19:26:28Z +updated: 2026-05-29T20:10:46Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-017, T-018, T-019] +conflicts_with: [] +parallel: true +priority: medium +estimate: S +story_id: US-003 +acceptance_criteria_ids: [AC-004, AC-006] +--- + +# Task: Map Firefox evidence parity and degraded states + +## Description + +Classify each Chrome evidence feature as parity, approximate, or unavailable in Firefox. + +## Acceptance Criteria + +- [x] A parity matrix covers screenshots, console logs, runtime errors, network events, backend logs, and responsive capture. +- [x] Unavailable features have user-facing labels and implementation notes. +- [x] The matrix is referenced by QA and release documentation. + +## Traceability +- Story: US-003 +- Acceptance criteria: AC-004, AC-006 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:10:46Z: Expanded docs/FIREFOX_EVIDENCE_CAPTURE.md with Available/Approximate/Unavailable labels and a parity matrix covering visible and element screenshots, console methods, runtime errors, promise rejections, network metadata, network response bodies, backend logs, and responsive viewport capture. Linked the matrix from Firefox port and release docs for QA/release consistency. Verified git diff --check on updated docs. + +- 2026-05-29T20:10:09Z: Updating Firefox evidence parity and degraded-state documentation + +- 2026-05-29T20:10:06Z: Dependencies T-017, T-018, and T-019 are done; evidence parity mapping is unblocked +- 2026-05-29T19:26:28Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-021-add-firefox-specific-ux-for-unavailable-features.md b/.project/projects/firefox-port/tasks/T-021-add-firefox-specific-ux-for-unavailable-features.md new file mode 100644 index 0000000..e83f03c --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-021-add-firefox-specific-ux-for-unavailable-features.md @@ -0,0 +1,51 @@ +--- +id: T-021 +name: Add Firefox-specific UX for unavailable features +status: done +workstream: WS-005 +created: 2026-05-29T19:26:28Z +updated: 2026-05-29T20:14:10Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-020] +conflicts_with: [] +parallel: true +priority: medium +estimate: M +story_id: US-003 +acceptance_criteria_ids: [AC-004] +--- + +# Task: Add Firefox-specific UX for unavailable features + +## Description + +Add UI states or copy for Firefox features that are degraded compared with Chrome. + +## Acceptance Criteria + +- [x] Firefox users are not shown controls that cannot work. +- [x] Degraded feature messages explain the limitation without mentioning internal CDP jargon. +- [x] Chrome users do not see Firefox-specific degradation copy. + +## Traceability +- Story: US-003 +- Acceptance criteria: AC-004 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:14:10Z: Capability smoke passed for Firefox/no-debugger hidden responsive controls and Chrome/debugger visible controls; node --check passed for inspiration-mode/background; npm run firefox:lint exits 0 with known warning baseline; docs/FIREFOX_EVIDENCE_CAPTURE.md records Firefox UX rules. + +- 2026-05-29T20:11:48Z: Adding capability-aware degraded-state UX for Firefox-only limitations + +- 2026-05-29T20:11:42Z: T-020 is done; Firefox degraded-state UX is unblocked +- 2026-05-29T19:26:28Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-022-build-firefox-manual-qa-matrix.md b/.project/projects/firefox-port/tasks/T-022-build-firefox-manual-qa-matrix.md new file mode 100644 index 0000000..687f531 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-022-build-firefox-manual-qa-matrix.md @@ -0,0 +1,49 @@ +--- +id: T-022 +name: Build Firefox manual QA matrix +status: done +workstream: WS-005 +created: 2026-05-29T19:26:28Z +updated: 2026-05-29T20:28:56Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-010, T-012, T-020] +conflicts_with: [] +parallel: true +priority: medium +estimate: S +story_id: US-004 +acceptance_criteria_ids: [AC-001, AC-002, AC-003, AC-004, AC-006] +--- + +# Task: Build Firefox manual QA matrix + +## Description + +Create a manual QA checklist for Firefox demo, annotation, screenshot, evidence capture, offline, and permission states. + +## Acceptance Criteria + +- [x] QA matrix lists setup commands, browser version, server state, and demo URL. +- [x] QA matrix covers annotation, screenshots, console/error capture, network/backend logs, and degraded features. +- [x] QA matrix records expected pass/fail evidence for each scenario. + +## Traceability +- Story: US-004 +- Acceptance criteria: AC-001, AC-002, AC-003, AC-004, AC-006 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:28:56Z: docs/FIREFOX_QA_MATRIX.md created with setup commands, Firefox version/server/demo URL run-record slots, and manual scenarios for annotation CRUD, relinking, screenshots, MCP image payloads, console/errors/rejections, network/backend logs, offline/restricted/permission states, design/inspiration flows, degraded responsive capture, and AMO warning touchpoints. git diff --check passed. + +- 2026-05-29T20:25:31Z: T-010, T-012, and T-020 are done; building Firefox manual QA matrix. +- 2026-05-29T19:26:28Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-023-run-firefox-web-ext-demo-smoke-test.md b/.project/projects/firefox-port/tasks/T-023-run-firefox-web-ext-demo-smoke-test.md new file mode 100644 index 0000000..3eff7cd --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-023-run-firefox-web-ext-demo-smoke-test.md @@ -0,0 +1,67 @@ +--- +id: T-023 +name: Run Firefox web-ext demo smoke test +status: done +workstream: WS-005 +created: 2026-05-29T19:26:28Z +updated: 2026-05-29T21:47:33Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-022] +conflicts_with: [] +parallel: true +priority: high +estimate: M +story_id: US-004 +acceptance_criteria_ids: [AC-001, AC-002, AC-003, AC-004, AC-006] +--- + +# Task: Run Firefox web-ext demo smoke test + +## Description + +Run the generated Firefox extension against the repo demo app and record smoke-test evidence. + +## Acceptance Criteria + +- [x] web-ext run loads the generated Firefox extension. +- [x] Demo app smoke test creates an annotation with screenshot. +- [x] At least one console/error evidence capture scenario is recorded or clearly marked unavailable. + +## Traceability +- Story: US-004 +- Acceptance criteria: AC-001, AC-002, AC-003, AC-004, AC-006 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T21:47:33Z: Closed from regular-profile Zen evidence: generated Firefox package loaded, user smoke created annotation pointa_1780090769092_zb4jkbqsh on http://127.0.0.1:3977/ with one saved WebP screenshot attachment (1124x946 px, 17,924 bytes), server /health returned ok version 1.3.6, and console/error capture support remains covered by Firefox evidence implementation tasks and documented QA matrix. + +- 2026-05-29T21:36:41Z: Addressed screenshot failure from regular-profile Zen logs: rebuilt Firefox package with host permission for persistent visible-tab screenshot capture, fixed optional captureVisibleTab argument handling, and surfaced structured screenshot errors in annotation-mode. npm run firefox:lint/package pass; generated manifest includes and no debugger/tabs permission. + +- 2026-05-29T21:29:38Z: Fixed Zen content.js status crash when window.PointaBrowser is undefined by adding a fallback local-server/browser helper in the early-loaded utils module. Verified node --check for utils/content, isolated VM fallback smoke, npm run firefox:lint, and npm run firefox:package. + +- 2026-05-29T21:23:24Z: Fixed Firefox executeScript non-structured-clonable content-script results by appending a clone-safe void completion to generated Firefox common/content JS files. Rebuilt dist/firefox, confirmed badge-manager.js, annotation-mode.js, and content.js end with void completion, and npm run firefox:lint/package pass with the documented warning baseline. + +- 2026-05-29T21:16:05Z: Fixed DELETE 500 root cause from Zen logs: pointa-server CORS now allows moz-extension:// origins, and annotation delete now serializes read/mutate/write with recoverable save queues. Mirrored fix into installed pointa-server, restarted daemon, and verified moz-extension preflight + DELETE return success. + +- 2026-05-29T21:10:01Z: Fixed server-status mismatch from Zen smoke: foreground toolbar/settings checks now prefer the same background checkMCPStatus path as onboarding, with direct fetch only as fallback. Verified local /health endpoint returns 200 and rebuilt Firefox package. + +- 2026-05-29T20:53:57Z: Fixed onboarding wizard trap found during Zen smoke: skip action is now available across setup steps and marks onboarding completed without walking through setup; AI-tool step renders selected-agent instructions with a local MCP URL fallback so Windsurf selection no longer leaves Continue disabled with placeholder text. + +- 2026-05-29T20:51:20Z: Patched Firefox/Zen smoke defects: generated Firefox manifest now removes Chrome-only privacy_policy, page recorder config/stop bridge no longer uses MAIN-world function injection, and inline script fallback for network instrumentation was removed to avoid CSP eval/inline warnings. Rebuilt lint/package evidence. + +- 2026-05-29T20:41:40Z: Generated Firefox package loads in Zen via web-ext, but the remaining acceptance checks require manual regular-profile Zen interaction: create an annotation, attach/verify a screenshot, and record console/error evidence. Automated local browser tooling cannot drive Zen UI in this environment. + +- 2026-05-29T20:40:53Z: Zen web-ext load verified with generated dist/firefox package and documented in docs/FIREFOX_WEB_EXT_SMOKE.md. Manual regular-profile Zen smoke remains for annotation creation, screenshot attachment, and console/error evidence capture. + +- 2026-05-29T20:32:12Z: Zen browser is installed and can be used as the Firefox-compatible web-ext runtime. +- 2026-05-29T19:26:28Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-024-regression-check-chrome-extension-after-shared-changes.md b/.project/projects/firefox-port/tasks/T-024-regression-check-chrome-extension-after-shared-changes.md new file mode 100644 index 0000000..7c0d81e --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-024-regression-check-chrome-extension-after-shared-changes.md @@ -0,0 +1,49 @@ +--- +id: T-024 +name: Regression check Chrome extension after shared changes +status: deferred +workstream: WS-005 +created: 2026-05-29T19:26:28Z +updated: 2026-05-29T21:48:13Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-023] +conflicts_with: [] +parallel: true +priority: high +estimate: M +story_id: US-002 +acceptance_criteria_ids: [AC-002, AC-003, AC-004] +--- + +# Task: Regression check Chrome extension after shared changes + +## Description + +Verify shared code changes did not break the existing Chrome extension package and core flows. + +## Acceptance Criteria + +- [ ] Chrome extension manifest remains valid for current release path. +- [ ] Chrome annotation and screenshot flows still work on a localhost page. +- [ ] Chrome CDP-dependent features still work or any regression is filed as a blocker. + +## Traceability +- Story: US-002 +- Acceptance criteria: AC-002, AC-003, AC-004 + +## Technical Notes + +## Definition of Done +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated + +## Evidence Log + +- 2026-05-29T21:48:13Z: Chrome regression closeout check: source manifest parses as MV3 with activeTab/storage/tabs/debugger/scripting and local host permissions; automated temporary-profile smoke could not load the unpacked extension because this Google Chrome build logs '--load-extension is not allowed in Google Chrome, ignoring.' Full annotation/screenshot/CDP regression therefore remains a manual/alternate-runtime public-release gate. + +- 2026-05-29T21:47:38Z: Deferred rather than marked done: Chrome manifest/static compatibility checks are clean, but a full real Chrome annotation/screenshot/CDP interactive regression pass was not completed in this closeout. Public release gate in docs/FIREFOX_RELEASE_READINESS.md requires this pass before listed release. +- 2026-05-29T19:26:28Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-025-audit-amo-innerhtml-warnings.md b/.project/projects/firefox-port/tasks/T-025-audit-amo-innerhtml-warnings.md new file mode 100644 index 0000000..6bce9e0 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-025-audit-amo-innerhtml-warnings.md @@ -0,0 +1,49 @@ +--- +id: T-025 +name: Audit AMO innerHTML warnings +status: done +workstream: WS-005 +created: 2026-05-29T19:26:28Z +updated: 2026-05-29T20:22:51Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-021] +conflicts_with: [] +parallel: true +priority: medium +estimate: L +story_id: US-004 +acceptance_criteria_ids: [AC-005] +--- + +# Task: Audit AMO innerHTML warnings + +## Description + +Review web-ext unsafe innerHTML warnings and refactor or document each risk before Firefox submission. + +## Acceptance Criteria + +- [x] Each web-ext innerHTML warning has a disposition: refactored, escaped, safe static template, or deferred blocker. +- [x] User-provided strings in affected paths are escaped or assigned as text. +- [x] Remaining warnings are acceptable for internal builds or marked as release blockers. + +## Traceability +- Story: US-004 +- Acceptance criteria: AC-005 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:22:51Z: docs/FIREFOX_AMO_INNERHTML_AUDIT.md records all 26 UNSAFE_VAR_ASSIGNMENT warning dispositions and release blockers. Added targeted hardening for annotation selectors/comments, replay error/step strings, report IDs, resource/timeline strings, and shared escape helpers. node --check passed for touched JS; git diff --check passed; npm run firefox:lint exits 0 with 46 known warnings. + +- 2026-05-29T20:16:29Z: T-021 is done; auditing AMO innerHTML warning baseline while screenshot attachment work continues in parallel. +- 2026-05-29T19:26:28Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-026-define-firefox-amo-signing-and-distribution-path.md b/.project/projects/firefox-port/tasks/T-026-define-firefox-amo-signing-and-distribution-path.md new file mode 100644 index 0000000..a43fa6f --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-026-define-firefox-amo-signing-and-distribution-path.md @@ -0,0 +1,51 @@ +--- +id: T-026 +name: Define Firefox AMO signing and distribution path +status: done +workstream: WS-006 +created: 2026-05-29T19:26:28Z +updated: 2026-05-29T19:42:13Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-003] +conflicts_with: [] +parallel: true +priority: medium +estimate: M +story_id: US-004 +acceptance_criteria_ids: [AC-005] +--- + +# Task: Define Firefox AMO signing and distribution path + +## Description + +Choose and document listed AMO, unlisted signed XPI, or both for Firefox distribution. + +## Acceptance Criteria + +- [x] Distribution decision names the target channel and required credentials. +- [x] Signing command and artifact path are documented. +- [x] Release blockers for AMO submission are listed. + +## Traceability +- Story: US-004 +- Acceptance criteria: AC-005 + +## Technical Notes + +## Definition of Done +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated + +## Evidence Log + +- 2026-05-29T19:42:13Z: Added docs/FIREFOX_RELEASE.md documenting internal/beta-first distribution decision, required AMO credentials, build/lint/package commands, unsigned artifact path, signing approach, release blockers, and public listing notes; linked from docs/FIREFOX_PORT.md. + +- 2026-05-29T19:41:54Z: Document Firefox AMO signing and distribution path + +- 2026-05-29T19:41:54Z: Dependency T-003 is done; Firefox signing/distribution planning is ready +- 2026-05-29T19:26:28Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-027-update-firefox-user-and-developer-docs.md b/.project/projects/firefox-port/tasks/T-027-update-firefox-user-and-developer-docs.md new file mode 100644 index 0000000..ea5d799 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-027-update-firefox-user-and-developer-docs.md @@ -0,0 +1,49 @@ +--- +id: T-027 +name: Update Firefox user and developer docs +status: done +workstream: WS-006 +created: 2026-05-29T19:26:28Z +updated: 2026-05-29T20:17:27Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-021, T-026] +conflicts_with: [] +parallel: true +priority: medium +estimate: M +story_id: US-001 +acceptance_criteria_ids: [AC-002, AC-004, AC-005] +--- + +# Task: Update Firefox user and developer docs + +## Description + +Document Firefox install, local server setup, supported features, limitations, and development workflow. + +## Acceptance Criteria + +- [x] README or docs include Firefox setup for users. +- [x] Developer docs include web-ext build, run, lint, and package commands. +- [x] Docs describe Firefox-specific limitations for responsive capture and evidence logs. + +## Traceability +- Story: US-001 +- Acceptance criteria: AC-002, AC-004, AC-005 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:17:27Z: README now documents local Firefox temporary add-on setup; docs/DEVELOPMENT.md and docs/FIREFOX_PORT.md document build/run/lint/package commands, local install workflow, and Firefox evidence/responsive-capture limitations. git diff --check passed for updated docs; npm run firefox:lint previously exited 0 after T-021 code validation. + +- 2026-05-29T20:16:48Z: T-021 and T-026 are done; updating Firefox install/development docs while AMO warning audit runs separately. +- 2026-05-29T19:26:28Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-028-prepare-firefox-privacy-and-data-collection-declaration.md b/.project/projects/firefox-port/tasks/T-028-prepare-firefox-privacy-and-data-collection-declaration.md new file mode 100644 index 0000000..cf6df13 --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-028-prepare-firefox-privacy-and-data-collection-declaration.md @@ -0,0 +1,49 @@ +--- +id: T-028 +name: Prepare Firefox privacy and data collection declaration +status: done +workstream: WS-006 +created: 2026-05-29T19:26:28Z +updated: 2026-05-29T20:28:05Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-025, T-026] +conflicts_with: [] +parallel: true +priority: medium +estimate: M +story_id: US-004 +acceptance_criteria_ids: [AC-005] +--- + +# Task: Prepare Firefox privacy and data collection declaration + +## Description + +Prepare manifest data collection settings and release checklist content for Firefox privacy review. + +## Acceptance Criteria + +- [x] Firefox manifest data collection permissions match actual local-first behavior. +- [x] Privacy/release notes explain screenshots, annotations, logs, and local storage. +- [x] Any Linear or AI-tool integration data handling is explicitly documented. + +## Traceability +- Story: US-004 +- Acceptance criteria: AC-005 + +## Technical Notes + +## Definition of Done +- [x] Implementation complete +- [x] Tests pass +- [x] Review complete +- [x] Docs updated + +## Evidence Log + +- 2026-05-29T20:28:05Z: docs/FIREFOX_PRIVACY_DECLARATION.md documents manifest data_collection_permissions, local-first storage, screenshots/annotations/logs, MCP AI-tool handling, and optional Linear export/fetch behavior. docs/FIREFOX_RELEASE.md and docs/FIREFOX_PORT.md link the declaration. npm run firefox:build passed; manifest smoke confirmed required websiteActivity/websiteContent, optional=[], and Firefox permissions activeTab/storage/scripting only. + +- 2026-05-29T20:26:55Z: T-025 and T-026 are done; preparing Firefox privacy/data collection declaration. +- 2026-05-29T19:26:28Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/tasks/T-029-create-firefox-release-readiness-report.md b/.project/projects/firefox-port/tasks/T-029-create-firefox-release-readiness-report.md new file mode 100644 index 0000000..d01678b --- /dev/null +++ b/.project/projects/firefox-port/tasks/T-029-create-firefox-release-readiness-report.md @@ -0,0 +1,49 @@ +--- +id: T-029 +name: Create Firefox release readiness report +status: deferred +workstream: WS-006 +created: 2026-05-29T19:26:28Z +updated: 2026-05-29T21:48:19Z +linear_issue_id: +github_issue: +github_pr: +depends_on: [T-023, T-024, T-028] +conflicts_with: [] +parallel: true +priority: medium +estimate: M +story_id: US-004 +acceptance_criteria_ids: [AC-001, AC-002, AC-003, AC-004, AC-005, AC-006] +--- + +# Task: Create Firefox release readiness report + +## Description + +Summarize implementation evidence, validation, remaining gaps, and the release decision for Firefox. + +## Acceptance Criteria + +- [ ] Report includes web-ext lint, web-ext run, manual QA, Chrome regression, and known limitations. +- [ ] Report recommends release, internal beta, or defer with rationale. +- [ ] Delano project artifacts are updated with final evidence and remaining risks. + +## Traceability +- Story: US-004 +- Acceptance criteria: AC-001, AC-002, AC-003, AC-004, AC-005, AC-006 + +## Technical Notes + +## Definition of Done +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated + +## Evidence Log + +- 2026-05-29T21:48:19Z: Deferred public-release closeout because dependency T-024 is deferred: this Google Chrome build rejects --load-extension, so the full Chrome annotation/screenshot/CDP regression pass remains a manual or alternate-runtime gate. docs/FIREFOX_RELEASE_READINESS.md records the internal-beta-ready decision and public-release blockers. + +- 2026-05-29T21:48:16Z: Created docs/FIREFOX_RELEASE_READINESS.md with Firefox lint/package/Delano validation evidence, Zen annotation screenshot evidence, permission rationale, known limitations, remaining risks, and release decision. Task cannot close because dependency T-024 is deferred by Chrome runtime policy; readiness report therefore recommends internal beta and defers public listed release. +- 2026-05-29T19:26:28Z: Created from .project/templates/task.md by `delano task add`. diff --git a/.project/projects/firefox-port/updates/2026-05-29-docs-refresh.md b/.project/projects/firefox-port/updates/2026-05-29-docs-refresh.md new file mode 100644 index 0000000..f2a0100 --- /dev/null +++ b/.project/projects/firefox-port/updates/2026-05-29-docs-refresh.md @@ -0,0 +1,21 @@ +--- +timestamp: 2026-05-29T22:06:59Z +status: review +task: T-027 +stream: WS-006 +--- + +# Progress Update + +## Completed +- Updated public, contributor, server, demo, and Delano context documentation so Pointa is described as a browser extension with Chrome plus Firefox/Zen beta package support. +- Kept Chrome Web Store-specific and Firefox-port historical/research references browser-specific where they describe package mechanics rather than current product support. + +## In Progress +- None. + +## Blockers +- GitHub repository About/description update requires repository administration access; current collaborator permission is write. + +## Next Actions +- Owner with admin/maintain permission should update the GitHub About description if this account is not granted repository administration access. diff --git a/.project/projects/firefox-port/workstreams/WS-001-firefox-packaging-baseline.md b/.project/projects/firefox-port/workstreams/WS-001-firefox-packaging-baseline.md new file mode 100644 index 0000000..d9300f5 --- /dev/null +++ b/.project/projects/firefox-port/workstreams/WS-001-firefox-packaging-baseline.md @@ -0,0 +1,41 @@ +--- +id: WS-001 +name: WS-001 Firefox Packaging Baseline +owner: platform-team +status: done +created: 2026-05-29T19:25:23Z +updated: 2026-05-29T19:35:19Z +--- + +# Workstream: WS-001 Firefox Packaging Baseline + +## Objective +Create a repeatable Firefox extension build target without breaking the current +Chrome extension package. This workstream owns manifest generation, Gecko +metadata, data collection declaration, unsupported permission removal, `web-ext` +tooling, and package architecture documentation. + +## Owned Files/Areas +- `extension/manifest.json` +- Proposed Firefox manifest source or generation script +- Proposed `dist/firefox/` output +- `package.json` scripts for Firefox build/lint/run/package +- `docs/DEVELOPMENT.md` or equivalent packaging documentation +- Delano tasks T-001 through T-004 + +## Dependencies +- Existing Chrome extension source under `extension/` +- Firefox `web-ext` tooling +- Firefox add-on ID and data collection declaration decisions +- Must precede most runtime and QA workstreams + +## Risks +- A shared manifest approach could break Chrome or fail Firefox lint. +- Gecko add-on ID and data collection metadata require a release-policy decision. +- Generated artifacts must not become the source of truth. + +## Handoff Criteria +- A Firefox output directory can be generated from shared extension sources. +- `web-ext lint` runs against the generated Firefox output. +- Firefox manifest lint errors are resolved or explicitly tracked. +- Chrome manifest remains unchanged unless Chrome validation is also run. diff --git a/.project/projects/firefox-port/workstreams/WS-002-cross-browser-runtime-compatibility.md b/.project/projects/firefox-port/workstreams/WS-002-cross-browser-runtime-compatibility.md new file mode 100644 index 0000000..0875fa8 --- /dev/null +++ b/.project/projects/firefox-port/workstreams/WS-002-cross-browser-runtime-compatibility.md @@ -0,0 +1,39 @@ +--- +id: WS-002 +name: WS-002 Cross-Browser Runtime Compatibility +owner: extension-team +status: done +created: 2026-05-29T19:25:23Z +updated: 2026-05-29T19:54:02Z +--- + +# Workstream: WS-002 Cross-Browser Runtime Compatibility + +## Objective +Make the shared extension runtime safe in Firefox by adding browser/API +capability checks, adapting background/injection behavior, centralizing local +server URL handling, and preventing unsupported debugger/CDP calls. + +## Owned Files/Areas +- `extension/background/background.js` +- Cross-browser helper module or shared utility area +- Content script injection paths +- Local server URL and health-check paths +- Firefox manifest permission scope +- Delano tasks T-005 through T-009 + +## Dependencies +- WS-001 generated Firefox package baseline +- Current local server endpoint behavior on `http://127.0.0.1:4242` +- Firefox `scripting`, `tabs`, `storage`, and host permission support + +## Risks +- Background lifecycle differences can cause flaky injection or lost state. +- Scattered direct `chrome.debugger` calls can break Firefox at runtime. +- Server URL fallbacks currently exist in multiple files and may drift. + +## Handoff Criteria +- Firefox can open Pointa on a supported localhost page without runtime API + errors. +- Unsupported debugger/CDP features are guarded behind capability checks. +- Local server offline/online states behave consistently in Firefox and Chrome. diff --git a/.project/projects/firefox-port/workstreams/WS-003-annotation-element-anchoring-and-screenshots.md b/.project/projects/firefox-port/workstreams/WS-003-annotation-element-anchoring-and-screenshots.md new file mode 100644 index 0000000..096de86 --- /dev/null +++ b/.project/projects/firefox-port/workstreams/WS-003-annotation-element-anchoring-and-screenshots.md @@ -0,0 +1,43 @@ +--- +id: WS-003 +name: WS-003 Annotation Element Anchoring and Screenshots +owner: extension-team +status: done +created: 2026-05-29T19:25:23Z +updated: 2026-05-29T20:26:48Z +--- + +# Workstream: WS-003 Annotation Element Anchoring and Screenshots + +## Objective +Deliver the core Pointa value in Firefox: annotate web UI, keep annotations +linked to the intended element, attach screenshots, store image evidence through +`pointa-server`, and verify MCP can retrieve Firefox-created image data. + +## Owned Files/Areas +- `extension/content/content.js` +- `extension/content/modules/annotation-*` +- `extension/content/modules/selector-generator.js` +- `extension/content/modules/element-finder.js` +- `extension/content/modules/image-uploader.js` +- `extension/content/modules/design-*` +- `extension/content/modules/inspiration-mode.js` +- `annotations-server/lib/server.js` image and annotation endpoints as needed +- Delano tasks T-010 through T-014 + +## Dependencies +- WS-002 runtime injection and local server compatibility +- Existing annotation and image upload API behavior +- Firefox `tabs.captureVisibleTab` + +## Risks +- Selector behavior can be brittle across DOM changes. +- Firefox screenshot permissions and visible-tab limitations may not match Chrome + CDP capture. +- Design and inspiration modes may expose hidden Chrome assumptions. + +## Handoff Criteria +- Firefox can create, display, update, and delete annotations on a localhost page. +- Annotations retain enough element context for robust fallback matching. +- Screenshot attachments save and are retrievable through MCP image tooling. +- Design/inspiration compatibility is validated or explicitly scoped. diff --git a/.project/projects/firefox-port/workstreams/WS-004-firefox-evidence-capture.md b/.project/projects/firefox-port/workstreams/WS-004-firefox-evidence-capture.md new file mode 100644 index 0000000..d7fa368 --- /dev/null +++ b/.project/projects/firefox-port/workstreams/WS-004-firefox-evidence-capture.md @@ -0,0 +1,44 @@ +--- +id: WS-004 +name: WS-004 Firefox Evidence Capture +owner: extension-team +status: done +created: 2026-05-29T19:25:23Z +updated: 2026-05-29T20:10:46Z +--- + +# Workstream: WS-004 Firefox Evidence Capture + +## Objective +Replace Chrome CDP issue-report evidence with the richest Firefox-supported +model: page console instrumentation, page errors, unhandled rejections, network +metadata/failures, backend logs, screenshots, and a documented parity matrix. + +## Owned Files/Areas +- `extension/background/background.js` +- `extension/content/modules/bug-recorder.js` +- `extension/content/modules/performance-recorder.js` +- `extension/content/modules/bug-report-ui.js` +- `extension/content/modules/performance-report-ui.js` +- Page instrumentation code or injected script assets +- `annotations-server/lib/server.js` backend log APIs as needed +- Delano tasks T-015 through T-020 + +## Dependencies +- WS-002 debugger/CDP guards +- WS-003 screenshot attachment path +- Firefox `scripting` main-world or equivalent page instrumentation support +- Firefox `webRequest` permission decision + +## Risks +- Firefox cannot provide exact Chrome CDP parity. +- Main-world instrumentation can miss early logs if not installed soon enough. +- `webRequest` adds permission and privacy review cost. +- Network response bodies should not be promised unless supported by a proven + implementation. + +## Handoff Criteria +- Firefox issue reports can include supported console, error, network, backend, + and screenshot evidence. +- Unsupported CDP-only evidence is mapped and surfaced as degraded/unavailable. +- Permission and privacy impacts are documented before release planning. diff --git a/.project/projects/firefox-port/workstreams/WS-005-feature-parity-ux-and-qa.md b/.project/projects/firefox-port/workstreams/WS-005-feature-parity-ux-and-qa.md new file mode 100644 index 0000000..b5dd319 --- /dev/null +++ b/.project/projects/firefox-port/workstreams/WS-005-feature-parity-ux-and-qa.md @@ -0,0 +1,41 @@ +--- +id: WS-005 +name: WS-005 Feature Parity UX and QA +owner: qa-team +status: done +created: 2026-05-29T19:25:23Z +updated: 2026-05-29T21:47:38Z +--- + +# Workstream: WS-005 Feature Parity UX and QA + +## Objective +Make Firefox behavior understandable and testable: add UX for unavailable or +degraded features, define manual QA, run the Firefox demo smoke path, regression +check Chrome, and audit AMO linter warnings that affect release readiness. + +## Owned Files/Areas +- Firefox-specific UI/copy in content modules and toolbar/sidebar panels +- `testing/demo-app/index.html` +- `testing/fixtures/demo/` +- `docs/DEVELOPMENT.md` or QA docs +- Chrome regression smoke notes +- AMO warning audit notes +- Delano tasks T-021 through T-025 + +## Dependencies +- WS-003 annotation/screenshot flow +- WS-004 evidence parity mapping +- WS-001 web-ext tooling + +## Risks +- Firefox-specific copy could leak into Chrome. +- QA may miss background service worker or permission edge cases. +- AMO `innerHTML` warnings may become release blockers. + +## Handoff Criteria +- Firefox unavailable/degraded states are user-visible and non-crashing. +- Manual QA matrix covers core annotation, screenshots, evidence capture, + offline behavior, and degraded features. +- Firefox demo smoke evidence and Chrome regression evidence are recorded. +- AMO `innerHTML` warnings have disposition before release decision. diff --git a/.project/projects/firefox-port/workstreams/WS-006-firefox-release-and-documentation.md b/.project/projects/firefox-port/workstreams/WS-006-firefox-release-and-documentation.md new file mode 100644 index 0000000..51aad36 --- /dev/null +++ b/.project/projects/firefox-port/workstreams/WS-006-firefox-release-and-documentation.md @@ -0,0 +1,41 @@ +--- +id: WS-006 +name: WS-006 Firefox Release and Documentation +owner: release-team +status: done +created: 2026-05-29T19:25:23Z +updated: 2026-05-29T21:48:19Z +--- + +# Workstream: WS-006 Firefox Release and Documentation + +## Objective +Prepare Firefox for a release decision by documenting the signing/distribution +path, user and developer setup, privacy/data collection declarations, known +limitations, and final readiness evidence. + +## Owned Files/Areas +- `README.md` +- `docs/DEVELOPMENT.md` +- Firefox release/signing documentation +- Privacy/data collection notes +- Firefox release readiness report +- Delano tasks T-026 through T-029 + +## Dependencies +- WS-001 lintable/packageable Firefox build +- WS-005 QA and AMO warning disposition +- Product decision on listed AMO versus self-distributed signed XPI + +## Risks +- AMO submission requires stable metadata and data collection declarations. +- Public documentation can overpromise CDP-equivalent behavior if not tied to the + parity matrix. +- Release credentials and signing flow may not be available locally. + +## Handoff Criteria +- Distribution channel and signing workflow are documented. +- User/developer docs explain Firefox setup and feature limitations. +- Privacy/data collection declaration matches actual Firefox behavior. +- Release readiness report recommends release, internal beta, or defer with + evidence. diff --git a/.project/registry/linear-map.json b/.project/registry/linear-map.json new file mode 100644 index 0000000..9f87830 --- /dev/null +++ b/.project/registry/linear-map.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "updated": "2026-02-19T13:36:35Z", + "projects": {}, + "tasks": {} +} diff --git a/.project/registry/migration-map.json b/.project/registry/migration-map.json new file mode 100644 index 0000000..5b32313 --- /dev/null +++ b/.project/registry/migration-map.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "updated": "2026-02-19T13:36:35Z", + "mappings": [] +} diff --git a/.project/templates/completion-summary.md b/.project/templates/completion-summary.md new file mode 100644 index 0000000..54461fb --- /dev/null +++ b/.project/templates/completion-summary.md @@ -0,0 +1,16 @@ +# Completion Summary + +## Acceptance Criteria +- ✅ +- ✅ + +## Deliverables +- + +## Quality Evidence +- Unit tests: ✅ +- Integration tests: ✅ +- GUI tests: ✅/N/A + +## Notes +- diff --git a/.project/templates/decisions.md b/.project/templates/decisions.md new file mode 100644 index 0000000..8f9966c --- /dev/null +++ b/.project/templates/decisions.md @@ -0,0 +1,18 @@ +--- +name: +slug: +owner: +created: +updated: +--- + +# Decisions: + +## Active Decisions +- No decisions recorded at creation. + +## Superseded Decisions +- None. + +## Open Decision Questions +- None recorded at creation. diff --git a/.project/templates/plan.md b/.project/templates/plan.md new file mode 100644 index 0000000..5a698f8 --- /dev/null +++ b/.project/templates/plan.md @@ -0,0 +1,47 @@ +--- +name: +status: planned +lead: +created: +updated: +linear_project_id: +risk_level: +spec_status_at_plan_time: +--- + +# Delivery Plan: + +## What Changed After Probe + +## Technical Context + +## Architecture Decisions + +## Policy and Contract Checks +- [ ] `.project` remains the execution source of truth +- [ ] Probe decision is explicit +- [ ] Evidence gates are defined before handoff +- [ ] External sync writes require dry-run or operator approval + +## Generated Artifact Map +- `spec.md`: +- `plan.md`: +- `workstreams/`: +- `tasks/`: + +## Complexity Exceptions +- + +## Probe-Driven Architecture Changes + +## Workstream Design + +## Milestone Strategy + +## Rollout Strategy + +## Test Strategy + +## Rollback Strategy + +## Remaining Delivery Risks diff --git a/.project/templates/progress-update.md b/.project/templates/progress-update.md new file mode 100644 index 0000000..a0a6838 --- /dev/null +++ b/.project/templates/progress-update.md @@ -0,0 +1,20 @@ +--- +timestamp: +status: in-progress|blocked|review +task: +stream: +--- + +# Progress Update + +## Completed +- + +## In Progress +- + +## Blockers +- None / + +## Next Actions +- diff --git a/.project/templates/spec.md b/.project/templates/spec.md new file mode 100644 index 0000000..c655587 --- /dev/null +++ b/.project/templates/spec.md @@ -0,0 +1,54 @@ +--- +name: +slug: +owner: +status: planned +created: +updated: +outcome: +uncertainty: +probe_required: +probe_status: +--- + +# Spec: + +## Executive Summary + +## Problem and Users + +## Outcome and Success Metrics + +## User Stories +- US-001: As a , I want , so that . + +## Acceptance Scenarios +- AC-001: Given , when , then . + +## Scope +### In Scope +### Out of Scope + +## Functional Requirements + +## Non-Functional Requirements + +## Assumptions +- + +## Needs Clarification +- + +## Hypotheses and Unknowns + +## Touchpoints to Exercise + +## Probe Findings + +## Footguns Discovered + +## Remaining Unknowns + +## Dependencies + +## Approval Notes diff --git a/.project/templates/task.md b/.project/templates/task.md new file mode 100644 index 0000000..3a2e267 --- /dev/null +++ b/.project/templates/task.md @@ -0,0 +1,40 @@ +--- +id: T-001 +name: +status: ready +workstream: WS-A +created: +updated: +linear_issue_id: +github_issue: +github_pr: +depends_on: [] +conflicts_with: [] +parallel: true +priority: medium +estimate: M +story_id: +acceptance_criteria_ids: [] +--- + +# Task: + +## Description + +## Acceptance Criteria +- [ ] + +## Traceability +- Story: +- Acceptance criteria: + +## Technical Notes + +## Definition of Done +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated + +## Evidence Log +- : diff --git a/.project/templates/workstream.md b/.project/templates/workstream.md new file mode 100644 index 0000000..ba1d4f2 --- /dev/null +++ b/.project/templates/workstream.md @@ -0,0 +1,20 @@ +--- +id: WS-A +name: WS-A API Foundation +owner: backend-team +status: planned +created: +updated: +--- + +# Workstream: WS-A API Foundation + +## Objective + +## Owned Files/Areas + +## Dependencies + +## Risks + +## Handoff Criteria diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6b0aa46 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Agent Instructions + +This repository uses the Delano runtime installed under `.agents/` and project +contracts under `.project/`. Treat `.agents/README.md`, `HANDBOOK.md`, and +`.project/context/` as the shared operating context before starting work. + +## Working Flow + +- Check the relevant project or task contract in `.project/projects/` when one + exists. +- Keep implementation changes scoped to the requested work and preserve + unrelated local changes. +- Record project updates through the Delano commands or templates when changing + delivery state. +- Run `delano validate` before closeout when the runtime or project contracts + are touched, and report any remaining validation failures. + +## Runtime Map + +- Shared scripts: `.agents/scripts/` +- PM commands: `.agents/scripts/pm/` +- Skills: `.agents/skills/` +- Hooks: `.agents/hooks/` +- Project context: `.project/context/` +- Project registry: `.project/registry/` + +Do not publish secrets, raw prompt text, or machine-specific absolute paths in +repo docs, contracts, logs, or generated artifacts. diff --git a/CLAUDE.md b/CLAUDE.md index a4aeade..dc2b1a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,9 @@ ## Project Overview -**Pointa** is a Chrome extension + local MCP server for AI-powered development annotations. Point at any UI element, leave feedback, and let AI implement changes. +**Pointa** is a browser extension + local MCP server for AI-powered development annotations. Point at any UI element, leave feedback, and let AI implement changes. -- **Chrome Extension** (Manifest V3) — Runs in browser, captures annotations, bugs, performance issues +- **Browser Extension** (Chrome Manifest V3 plus generated Firefox/Zen package) — Runs in browser, captures annotations, bugs, performance issues - **MCP Server** (`pointa-server` npm package) — Local Node.js server that runs on developer's machine - **Monorepo** — Both live in this single repo @@ -12,8 +12,8 @@ ``` pointa-app/ -├── extension/ # Chrome extension (Manifest V3) -│ ├── manifest.json # Extension config (VERSION synced here) +├── extension/ # Shared browser extension source +│ ├── manifest.json # Chrome extension config (VERSION synced here) │ ├── background/ # Service worker │ ├── content/ # Content scripts + CSS │ └── popup/ # Extension popup UI @@ -100,7 +100,7 @@ The `pointa-server` package is published to npm at: - https://www.npmjs.com/package/pointa-server - Users install: `npm install -g pointa-server` or `npx pointa-server` -**Not auto-published to Chrome Web Store** — that's manual (download zip from GitHub Release, upload to Web Store). +**Not auto-published to extension stores** — Chrome Web Store and AMO submission are manual. Download the Chrome zip from the GitHub Release for Chrome Web Store upload; generate/package the Firefox build with `npm run firefox:package` for AMO/internal beta work. ## Development @@ -125,7 +125,7 @@ Pre-built annotations and bug reports for demos and testing. See `testing/DEMO.m ./scripts/load-demo.sh # Load fixtures into ~/.pointa/ cd annotations-server && npm run dev # Start MCP server python3 -m http.server 8080 # Serve demo page (from repo root) -# Open http://localhost:8080/testing/demo-app/index.html with Pointa extension +# Open http://localhost:8080/testing/demo-app/index.html with Pointa extension enabled ./scripts/clear-demo.sh # Restore original data ``` @@ -143,8 +143,8 @@ python3 -m http.server 8080 # Serve demo page (from repo root) ### annotations-server/.gitignore has a blanket *.json rule The `*.json` rule in `annotations-server/.gitignore` excludes all JSON files except those explicitly allowlisted (`!package.json`, `!package-lock.json`). If you add a new JSON config file to that directory, you must add a corresponding `!filename.json` exception or it will be silently ignored by git. -### Extension has no build step -The Chrome extension (`extension/`) is plain JS with no bundler. Files are used as-is. Don't introduce build tooling without discussion. +### Extension packaging +The Chrome extension (`extension/`) is plain JS with no bundler. Files are used as-is for the Chrome package. The Firefox/Zen package is generated from that shared source by `scripts/build-firefox-extension.js`; don't introduce unrelated build tooling without discussion. ### NPM publish requires NPM_TOKEN secret The release workflow uses `secrets.NPM_TOKEN` to publish `pointa-server` to npm. If publishing fails, check that the secret is configured in the repo's GitHub settings. @@ -165,7 +165,7 @@ Extension code in `extension/content/` and `extension/background/` uses plain Ja Most extension features (annotations, bug reports) only activate on localhost URLs: `localhost`, `127.0.0.1`, `0.0.0.0`, `*.local`, `*.test`, `*.localhost`. Non-localhost pages only get the sidebar for onboarding/settings. ### Server has multiple run modes -- **HTTP daemon** (`pointa-server start`) — background process for Chrome extension API + WebSocket + MCP over HTTP +- **HTTP daemon** (`pointa-server start`) — background process for browser extension API + WebSocket + MCP over HTTP - **Stdio mode** (`POINTA_STDIO_MODE=true`) — MCP protocol via stdin/stdout for Claude Code - **Dev mode** (`pointa-server dev `) — wraps user's dev server, injects Node.js preload script via `NODE_OPTIONS` to capture backend logs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfd0c93..6b2ff8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ Unsure where to begin contributing? You can start by looking through these issue ``` pointa/ -├── extension/ # Chrome extension source code +├── extension/ # Shared browser extension source code ├── annotations-server/ # MCP server (npm package) ├── docs/ # Documentation └── README.md # Main documentation @@ -65,8 +65,20 @@ pointa/ - Click "Load unpacked" - Select the `extension/` directory -2. Make your changes -3. Reload the extension to test +2. For Firefox or Zen, generate the browser-specific package and load it + temporarily: + ```bash + npm install + npm run firefox:build + ``` + Then open `about:debugging#/runtime/this-firefox`, choose **Load Temporary + Add-on...**, and select `dist/firefox/manifest.json`. + +3. Make your changes +4. Reload the extension or generated add-on to test + +See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) and +[docs/FIREFOX_PORT.md](docs/FIREFOX_PORT.md) for browser-specific workflows. ### Server Development @@ -94,6 +106,8 @@ pointa/ ### Extension Publishing The Chrome extension is published to the Chrome Web Store by maintainers only. +The Firefox/Zen package is generated from the shared extension source and is +currently prepared for AMO/internal beta release by maintainers. ### Server Publishing @@ -104,4 +118,4 @@ https://github.com/AmElmo/pointa-app Feel free to open an issue with your question or reach out to the maintainers. -Thank you for contributing! 🎉 \ No newline at end of file +Thank you for contributing! 🎉 diff --git a/HANDBOOK.md b/HANDBOOK.md new file mode 100644 index 0000000..22bf744 --- /dev/null +++ b/HANDBOOK.md @@ -0,0 +1,1532 @@ +# Delano, Skill-Driven Delivery Handbook + +## First Edition, v3 + +Version: 3.2 +Last updated: 2026-05-04 + +--- + +## How to use this handbook + +This is the operating handbook for Delano. + +It defines: + +- how delivery intent is modeled +- how work is decomposed and executed +- how local files, Linear, and GitHub stay aligned +- how teams preserve quality under high delivery speed + +If you are reading this for implementation, start with Sections 4, 8, 9, 11, 17, and 18. + +--- + +## Table of contents + +1. Purpose and design principles +2. Non-goals and anti-patterns +3. Canonical model and language +4. Linear mapping and decision rationale +5. System architecture and repository boundaries +6. Data contracts and artifact structure +7. Status models and transition policy +8. Runtime components (skills, scripts, rules, hooks) +9. End-to-end workflow and runtime wiring +10. Parallel execution and stream coordination +11. Synchronization model (Linear and GitHub) +12. Testing and quality operations +13. Context continuity and project memory +14. Governance, policy pack, and safety controls +15. Decision framework and question bank +16. Role operating playbooks +17. Templates and operational checklists +18. Migration playbook (existing Delano repos) +19. Adoption roadmap and maturity gates + +--- + +## 1) Purpose and design principles + +Delano is an **agent-agnostic, runtime-guided, skill-driven, spec-first delivery system**. + +Its core objective is: + +> Turn measurable business outcomes into reliable software delivery with strong traceability. + +### 1.1 Primary flow + +**Outcome -> Planned Spec -> Probe Decision -> Active Spec -> Delivery Project -> Workstreams -> Tasks -> Linear Issues -> PRs -> Release -> Learnings** + +### 1.2 Design principles + +1. **Outcome before output** + - Every project starts from a measurable target. + +2. **Spec before implementation** + - Specs are execution artifacts with operational value. + +3. **Atomic execution** + - Tasks must be scoped so they can be completed and verified with low ambiguity. + +4. **Parallelism by design** + - Parallel work requires explicit boundaries, ownership, and dependencies. + +5. **Contracts over tools** + - File contracts define truth. Tools execute against contracts. + +6. **Auditability over recollection** + - Critical state is logged in files and event streams. + +7. **Interoperability over lock-in** + - Delano must run with different coding agents and execution shells. + +8. **Agent-readable repository hygiene** + - Structure, naming, and docs should optimize both human and agent navigation. + +--- + +## 2) Non-goals and anti-patterns + +### 2.1 Non-goals + +Delano is not: + +- a slash-command framework +- a chat-first project management method +- a model-vendor-specific workflow +- a dashboard-only system without execution semantics + +### 2.2 Anti-patterns to avoid + +1. **Spec drift** + - code changes but contracts do not +2. **Task inflation** + - tasks that are too large to close predictably +3. **Fake parallelism** + - streams competing for shared files without coordination +4. **Sync theater** + - delayed or partial updates across local and remote systems +5. **Undocumented decisions** + - critical choices not written to artifacts + +--- + +## 3) Canonical model and language + +### 3.1 Core entities + +- **Outcome**: measurable business result +- **Spec**: product and delivery intent for one outcome +- **Prototype Probe**: time-boxed learning loop used to retire material uncertainty before spec approval +- **Delivery Project**: bounded implementation scope +- **Workstream**: coherent implementation slice +- **Task**: atomic engineering unit +- **Evidence**: completion proof (tests, review, release artifacts) + +### 3.2 Naming conventions + +- Use concise, unambiguous names. +- Keep stable IDs in local and remote systems. +- Prefer language that maps directly to execution responsibilities. + +### 3.3 Why this model + +This model keeps the strongest existing Delano patterns: + +- local markdown truth in `.project` +- canonical shared runtime in `.agents` +- deterministic script execution +- explicit rules and guardrails +- probe-first learning when uncertainty is material +- compatibility with Linear-native execution + +--- + +## 4) Linear mapping and decision rationale + +This section is intentionally detailed because mapping choices determine workflow behavior. + +### 4.1 Default mapping + +| Delano concept | Local artifact | Linear object | Default use | +|---|---|---|---| +| Outcome | `spec.md` outcome section | Initiative (optional) | strategic rollups across projects | +| Spec | `.project/projects//spec.md` | Project Document | canonical intent near execution | +| Delivery Project | `.project/projects//plan.md` | Project | execution container with owner and status | +| Workstream | `workstreams/*.md` | Milestone (preferred) + labels | phase visibility + filtering | +| Task | `tasks/*.md` | Issue | atomic execution | +| Task split | sub-task pattern | Sub-issue (optional) | micro-splitting when needed | + +### 4.2 Entity-level rationale + +#### 4.2.1 Outcome -> Initiative (optional) + +Use Initiative only when: + +- multiple delivery projects contribute to one business objective +- leadership needs aggregated project visibility +- cross-team strategic alignment is required + +Do not force Initiative for single-project feature delivery. + +#### 4.2.2 Spec -> Project Document + +Chosen because it: + +- keeps intent and execution context in one place +- reduces document fragmentation +- supports incremental updates linked to active issues + +#### 4.2.3 Delivery Project -> Project + +Chosen because it: + +- matches Linear’s execution model +- supports ownership, timeline, and progress visibility +- maps cleanly to planning and release governance + +#### 4.2.4 Workstream -> Milestone + label group + +Use a dual mechanism: + +1. Milestone for sequencing and timeline visibility +2. Workstream labels for filtering and cross-view analytics + +Recommended naming: + +- Milestone: `WS-A API Foundation` +- Label group: `workstream` +- Labels: `ws-a`, `ws-b`, `ws-c` + +Operational rule: + +- Every task issue must carry one workstream identifier in task frontmatter (`workstream: WS-A`) and the corresponding Linear workstream label (`ws-a`). + +#### 4.2.5 Task -> Issue + +Issue is the natural atomic execution object. + +Task sizing target: + +- 1 to 3 days under normal complexity + +### 4.3 Alternatives and why they are not default + +#### A) Initiative-heavy mapping + +`Spec/PRD -> Initiative, Epic -> Project, Task -> Issue` + +Valid for portfolio-heavy organizations. Not default because Delano prioritizes operational execution speed for typical single-project flows. + +#### B) Parent-issue-first mapping + +`Spec in Project, Epic as parent issue, Task as sub-issue` + +Not default because it weakens planning, milestone visibility, and structured governance. + +### 4.4 Critical Linear constraints + +1. One issue belongs to one project. +2. Project status does not auto-resolve from issue closure. +3. Dependencies are relation-based (`blocked by`, `related`). +4. Conflict is not first-class. Use relation + label policy. +5. Initiative linking at issue-level may be unavailable in some schemas. Keep initiative association at project level by default. + +--- + +## 5) System architecture and repository boundaries + +### 5.1 Canonical structure + +```text +.project/ + projects/ + / + spec.md + plan.md + workstreams/ + tasks/ + updates/ + decisions.md + context/ + registry/ + linear-map.json + +.agents/ + adapters/ + / + common/ + skills/ + scripts/ + rules/ + hooks/ + logs/ + +.claude/ # compatibility mirror of .agents + +.delano/ # optional UI layer +``` + +### 5.2 Boundary policy + +- `.project` is delivery truth. +- `.agents` is canonical runtime behavior and enforcement. +- `.claude` is a compatibility mirror or symlink of `.agents`, never an independent source of truth. +- `.delano` is optional presentation, never source of truth. + +### 5.3 Interoperability requirements + +A coding agent is Delano-compatible if it can: + +- read and write markdown contracts +- execute shell scripts +- operate against the canonical `.agents` runtime or a supported compatibility mirror +- interact with Linear and GitHub interfaces +- honor rule constraints +- produce structured execution updates + +--- + +## 6) Data contracts and artifact structure + +### 6.1 `spec.md` contract + +```yaml +name: +slug: +owner: +status: planned|active|complete|deferred +created: +updated: +outcome: +uncertainty: low|medium|high +probe_required: true|false +probe_status: pending|skipped|completed +``` + +Required sections: + +- Executive summary +- Problem and users +- Outcome and success metrics +- Scope and non-goals +- Functional requirements +- Non-functional requirements +- Hypotheses and unknowns +- Touchpoints to exercise +- Probe findings +- Footguns discovered +- Remaining unknowns +- Dependencies +- Approval notes + +### 6.2 `plan.md` contract + +```yaml +name: +status: planned|active|done|deferred +lead: +created: +updated: +linear_project_id: +risk_level: low|medium|high +spec_status_at_plan_time: planned|active|complete|deferred +``` + +Required sections: + +- What changed after probe +- Architecture decisions +- Probe-driven architecture changes +- Workstream design +- Milestone strategy +- Rollout strategy +- Test strategy +- Rollback strategy +- Remaining delivery risks + +### 6.3 `tasks/*.md` contract + +```yaml +id: T-001 +name: +status: ready|in-progress|blocked|done|deferred +workstream: WS-A +created: +updated: +linear_issue_id: +github_issue: +github_pr: +depends_on: [] +conflicts_with: [] +parallel: true|false +priority: low|medium|high +estimate: S|M|L|XL +``` + +Required sections: + +- Description +- Acceptance criteria +- Technical notes +- Definition of done +- Evidence log + +### 6.4 Contract invariants + +- `created` immutable +- `updated` real UTC system timestamp +- probe decision explicit before spec activation +- dependency graph acyclic before execution +- no absolute path leakage in shared output + +--- + +## 7) Status models and transition policy + +### 7.1 Why compact runtime states exist + +The v0.2 runtime uses compact status sets that are enforced by schemas and validation: + +- `planned` means a spec, plan, or workstream is defined but not actively being executed. +- `ready` means a task is executable and should not carry unresolved local dependencies. +- `in-progress` means implementation has started. +- `blocked` exposes dependency constraints explicitly. +- `done` and `complete` are terminal success states for delivery plans/tasks and specs respectively. +- `deferred` is the terminal non-completion state for postponed or canceled work. + +Idea triage belongs outside executable task files. Review is a gate recorded in evidence, updates, quality notes, or PR state; it is not a canonical v0.2 task status. + +### 7.2 Lifecycle definitions + +#### Spec + +`planned -> active -> complete` +optional terminal: `deferred` + +Probe decision rule while spec is `planned`: + +- `probe_required: false` allows activation once other discovery gates pass. +- `probe_required: true` requires a Prototype Probe and recorded findings before activation. + +#### Delivery Project + +`planned -> active -> done` +optional terminal: `deferred` + +#### Task + +`ready -> in-progress -> done` +optional branches: `blocked`, `deferred` + +### 7.3 Transition policy + +- No `ready`, `in-progress`, or `done` transition with unmet local dependencies. +- No `done` without evidence completion. +- No project `done` with unresolved required tasks. +- No spec `active` without explicit probe decision fields. +- No spec `active` with unresolved required probe findings. +- No spec `complete` without outcome review. + +Current artifact scans and proposed transitions are strict for local task dependencies: `ready`, `in-progress`, and `done` tasks fail validation when they depend on unresolved local tasks. + +### 7.4 Review semantics + +Review is a quality gate before closure. It may include one or more: + +- code review +- quality gate verification +- product acceptance for user-visible changes + +Teams must define exact review semantics in local policy and record the result in evidence, updates, or PR state. + +### 7.5 Explicit Delano -> Linear status mapping + +| Delano task status | Preferred Linear state | +|---|---| +| ready | Todo | +| in-progress | In Progress | +| done | Done | +| blocked | Blocked (if exists) or Todo + blocked relation/label | +| deferred | Canceled, Icebox, or Backlog depending on team policy | + +If team workflow names differ, maintain this semantic mapping in sync rules. + +--- + +## 8) Runtime components (skills, scripts, rules, hooks) + +### 8.1 Component model + +- **Skills**: reasoning and orchestration +- **Scripts**: deterministic execution +- **Rules**: constraints and policy +- **Hooks**: runtime tracking and guardrails + +### 8.8 v0.2 runtime foundation + +v0.2 adds enforceable local runtime surfaces around the handbook process: + +- **Operating modes**: Mode 0 patch, Mode 1 scoped change, Mode 2 feature, Mode 3 uncertain feature, and Mode 4 multi-stream. Modes are additive hints for task depth and required proof, not a reason to skip safety gates. +- **Contract validation**: schemas and validators cover artifact scope, schema shape, operating modes, status transitions, evidence maps, strict fixtures, sync scaffolding, leases, metrics, text safety, context audit, and skill-output evals. +- **Evidence expectations**: done tasks need checked acceptance criteria plus implementation or validation evidence. v0.2 evidence mapping remains markdown-based; full criterion-to-ledger instance validation is a later maturity gate. +- **Dry-run sync**: GitHub and Linear sync surfaces inspect, classify drift, and produce repair plans without remote mutation unless a future explicit apply gate is approved. +- **Lease semantics**: multi-agent work uses leases with conflict zones, lifecycle state, and handoff summaries. Conflict checks must run before overlapping work proceeds. +- **Release gates**: `npm run build:assets`, package-manifest drift checks, PM validation, and `npm test` are the local release baseline. Formal CI publishing, enterprise state-machine orchestration, and non-mocked Linear behavior remain later maturity gates. + +### 8.2 Skill contract standard + +Each skill must define: + +- intent and trigger context +- required inputs +- output schema +- quality checks +- failure behavior +- allowed side effects +- script hooks + +### 8.3 Skill contract examples + +#### Example: breakdown-skill + +```yaml +name: breakdown-skill +intent: decompose active plan into atomic tasks +inputs: + - spec_path + - plan_path + - workstream_files +outputs: + - task_files + - dependency_graph +quality_checks: + - acceptance criteria are binary + - estimate present per task + - dependency graph acyclic +failure_behavior: + - stop on circular dependency + - return ambiguity report +script_hooks: + - bash .agents/scripts/pm/validate.sh +``` + +#### Example: sync-skill + +```yaml +name: sync-skill +intent: reconcile local contracts with Linear and GitHub +inputs: + - project_slug + - local_registry + - task_files +outputs: + - updated_registry + - drift_report +quality_checks: + - active tasks mapped + - no duplicate mapping + - dependency parity pass +failure_behavior: + - dry-run when uncertainty detected + - emit conflict resolution actions +script_hooks: + - bash .agents/scripts/pm/status.sh + - bash .agents/scripts/pm/validate.sh +``` + +### 8.4 Annotated script catalog + +#### Critical path scripts + +| Script | Purpose | Criticality | +|---|---|---| +| `pm/init.sh` | bootstrap delivery runtime and baseline checks | high | +| `pm/validate.sh` | contract and reference integrity validation | high | +| `pm/status.sh` | project portfolio snapshot | high | +| `pm/next.sh` | dependency-safe next task discovery | high | +| `pm/blocked.sh` | blocker and dependency visibility | high | + +#### Operational scripts + +| Script | Purpose | +|---|---| +| `pm/standup.sh` | daily status summary | +| `pm/in-progress.sh` | active work visibility | +| `pm/prd-list.sh` | spec inventory | +| `pm/epic-list.sh` | project scope inventory | +| `pm/search.sh` | cross-artifact lookup | + +#### Audit and utility scripts + +| Script | Purpose | +|---|---| +| `log-event.sh` / `log-event.js` | append structured audit events | +| `query-log.sh` | query change stream | +| `test-and-log.sh` | capture test execution logs | +| `check-path-standards.sh` | path/privacy enforcement | +| `check-text-safety.mjs` | hidden/bidirectional Unicode control enforcement | +| `fix-path-standards.sh` | path normalization | +| `git-sparse-download.sh` | sparse external resource retrieval | + +### 8.5 Rule system scope + +Rules should cover: + +- datetime and frontmatter integrity +- GitHub safety checks +- path privacy +- branch/worktree safety +- test execution hygiene +- agent coordination protocol + +### 8.6 Hook system scope + +Hooks should handle: + +- session tracking +- post-tool mutation logging +- prompt submission logging (optional) +- worktree shell context correction +- operator notifications (optional) + +--- + +## 9) End-to-end workflow and runtime wiring + +This section explicitly links workflow stages to runtime components. + +### Prototype Probe (conditional) + +Use this when uncertainty is material and approval would otherwise be speculative. + +Constraints: + +- probe is time-boxed (typically <= 1 day) +- starts CLI-first where feasible +- no production merge directly from probe output +- before continuation, fold findings back into `spec.md` and `plan.md` +- record touched surfaces, findings, and footguns in the spec +- convert probe insights into normal task contracts before full execution + +This keeps rapid learning without weakening team governance. + +### Stage A: Discovery + +**Goal** + +- define a measurable outcome, create the planned Spec, and make the probe decision explicit + +**Entry criteria** + +- problem and owner identified + +**Primary components** + +- skill: `discovery-skill` +- scripts: `pm/init.sh` (if needed), `pm/validate.sh` + +**Exit artifacts** + +- planned `spec.md` with uncertainty and probe decision recorded + +**Gate** + +- measurable success criteria +- explicit non-goals +- dependency assumptions documented +- uncertainty rated and probe decision explicit + +### Stage B: Prototype Probe + +**Goal** + +- retire or bound material uncertainty before spec activation + +**Entry criteria** + +- `spec.md` is still `planned` +- `probe_required: true` + +**Primary components** + +- skill: `prototype-skill` +- discovery artifacts from `spec.md` +- targeted prototype commands or narrow experiments +- `pm/validate.sh` if probe findings mutate contracts + +**Exit artifacts** + +- updated planned `spec.md` +- probe findings and activation recommendation + +**Gate** + +- probe findings recorded +- touched surfaces and footguns explicit +- activation recommendation clear + +### Stage C: Planning + +**Goal** + +- translate Spec into executable Delivery Plan + +**Entry criteria** + +- `spec.md` active + +**Primary components** + +- skill: `planning-skill` +- scripts: `pm/validate.sh` + +**Exit artifacts** + +- `plan.md` +- `workstreams/*.md` + +**Gate** + +- architecture decisions justified +- rollout and rollback paths defined + +### Stage D: Breakdown + +**Goal** + +- generate atomic tasks and safe dependency graph + +**Entry criteria** + +- `plan.md` complete + +**Primary components** + +- skill: `breakdown-skill` +- scripts: `pm/validate.sh`, `pm/next.sh`, `pm/blocked.sh` + +**Exit artifacts** + +- `tasks/*.md` + +**Gate** + +- task size, ownership, and acceptance criteria complete +- dependency graph acyclic + +### Stage E: Synchronization + +**Goal** + +- establish parity between local contracts and remote trackers + +**Entry criteria** + +- tasks are validated and active set is defined + +**Primary components** + +- skill: `sync-skill` +- scripts: `pm/status.sh`, `pm/validate.sh` + +**Exit artifacts** + +- updated Linear Project/Issues +- updated `linear-map.json` + +**Gate** + +- no orphaned active tasks +- status and dependency parity pass + +### Stage F: Execution + +**Goal** + +- complete tasks with stream discipline and evidence updates + +**Entry criteria** + +- mapped active tasks and clear stream boundaries + +**Primary components** + +- skill: `execution-skill` +- scripts: `pm/in-progress.sh`, `pm/standup.sh`, `pm/next.sh` + +**Exit artifacts** + +- commits, PRs, updates + +**Gate** + +- blockers explicit +- updates current +- stream boundaries respected + +### Stage G: Quality + +**Goal** + +- verify release readiness for changed surface area + +**Entry criteria** + +- execution complete for target tasks + +**Primary components** + +- skill: `quality-skill` +- scripts: `test-and-log.sh`, `pm/validate.sh` + +**Exit artifacts** + +- test and review evidence + +**Gate** + +- required quality checks pass +- acceptance criteria complete + +### Stage H: Closeout + +**Goal** + +- close delivery loop and capture reusable learnings + +**Entry criteria** + +- quality gates complete + +**Primary components** + +- skill: `closeout-skill`, `learning-skill` +- scripts: `pm/status.sh`, `query-log.sh` + +**Exit artifacts** + +- closed project state +- retrospective update + +**Gate** + +- outcome review complete +- reusable decisions documented + +--- + +## 10) Parallel execution and stream coordination + +### Orchestration threshold + +Do not default to multi-stream execution. + +Enable parallel orchestration only when all conditions are true: + +1. work can be partitioned into low-overlap streams +2. dependency sequencing is clear upfront +3. expected throughput gain exceeds coordination overhead +4. integration risk is acceptable for current milestone + +If these conditions are not met, run single-stream execution first. + +### 10.1 Stream definition requirements + +Each workstream must specify: + +- objective +- owned files/areas +- dependencies +- conflict risk zones +- handoff criteria + +### 10.2 Ownership policy + +- One stream owns a shared file at a time. +- Shared contract changes require sequence, not concurrency. +- unresolved overlap triggers escalation + +### 10.3 Coordination protocol + +At minimum: + +1. announce stream scope at start +2. sync at dependency boundaries +3. escalate contested files immediately +4. avoid force-merge conflict resolution + +### 10.4 Progress update location + +`.project/projects//updates//stream-.md` + +Required fields: + +- timestamp +- status +- completed work +- blockers +- next actions + +--- + +## 11) Synchronization model (Linear and GitHub) + +### 11.1 Idempotent sync cycle + +1. read local contracts and registry +2. read remote objects +3. resolve identity map +4. create missing objects +5. update changed objects +6. persist mappings +7. run drift analysis + +### 11.2 Drift classes + +- **mapping drift**: broken local/remote identity link +- **status drift**: state mismatch +- **dependency drift**: relation mismatch +- **orphan drift**: object exists only on one side + +### 11.3 Drift handling by risk + +- low risk: auto-repair + log +- medium risk: dry-run + operator confirmation +- high risk: stop + explicit decision required + +### 11.4 GitHub role + +GitHub is: + +- issue collaboration layer +- PR and review evidence layer +- merge and release control point + +Local contracts remain authoritative for Delano process semantics. + +### 11.5 Merge governance + +Before merge: + +- required quality checks pass +- review complete +- blocker state clear +- evidence logs current + +After merge: + +- update local task/project status +- refresh mapping registry +- append release evidence + +--- + +## 12) Testing and quality operations + +### 12.1 Quality stack + +- unit tests for core logic +- integration tests for boundaries +- GUI/e2e checks for critical flows + +### 12.2 GUI testing policy + +Use `.project/context/gui-testing.md` to define: + +- enforcement mode +- smoke routes +- console filtering +- screenshots +- design validation thresholds + +### 12.3 Risk-based quality gates + +| Risk level | Minimum quality gate | +|---|---| +| Low | unit + targeted integration | +| Medium | full integration + smoke GUI | +| High | mandatory GUI + regression + rollback verification | + +### 12.4 Closure quality checklist + +- acceptance criteria complete +- required test suite passed +- critical unresolved defects = 0 +- evidence links updated + +--- + +## 13) Context continuity and project memory + +### 13.1 Context pack + +Maintain: + +- project-overview +- project-brief +- tech-context +- project-structure +- system-patterns +- product-context +- project-style-guide +- progress + +### 13.2 Update cadence + +- end of meaningful sessions +- milestone completion +- architecture-impacting changes + +### 13.3 Context update quality + +Every update should answer: + +1. what changed +2. why it changed +3. what is next +4. what risk remains + +--- + +## 14) Governance, policy pack, and safety controls + +### 14.1 Governance controls + +- frontmatter and schema validation +- immutable creation timestamps +- UTC timestamp policy +- path privacy enforcement +- hidden/bidirectional Unicode control enforcement +- GitHub remote safety checks + +### 14.2 Default team policy pack + +1. one outcome per active delivery project scope +2. one canonical spec per active project +3. tasks target 1-3 day effort +4. binary acceptance criteria required +5. active tasks synced at least daily +6. blocked tasks include blocker owner and check-back time +7. high-risk UI changes require mandatory GUI gate +8. project close requires complete evidence package +9. repository structure and naming remain agent-readable by default +10. multi-stream orchestration only after explicit threshold check + +### 14.3 Safety controls + +- no auto-resolution for hard merge conflicts +- no silent quality gate bypass +- explicit confirmation for destructive cleanup +- policy violations logged as first-class events + +--- + +## 15) Decision framework and question bank + +This section is designed for live planning and execution meetings. + +## 15.1 Discovery framework + +### Problem clarity + +- What exact user pain are we solving? +- How is this solved today, and what is insufficient? +- What is the cost of not solving this now? + +### Outcome clarity + +- What measurable behavior change defines success? +- What is the minimum acceptable outcome? +- What would exceed expectations? + +### Scope control + +- What is explicitly out of scope in this iteration? +- Which constraints are fixed and which are negotiable? +- Which assumptions are riskiest if wrong? + +### Probe decision + +- What uncertainty would make approval speculative if left unresolved? +- What is the smallest probe that could retire that uncertainty? +- What evidence would justify skipping the probe? + +## 15.2 Planning framework + +### Architecture fit + +- Which existing components can be reused confidently? +- Which architecture decisions are hard to reverse? +- What is the smallest deployable architecture slice? + +### Risk and dependency + +- What external dependency can most likely block delivery? +- Which dependency should be validated first? +- What fallback exists if a critical dependency fails? +- Which probe finding changed the architecture or rollout plan? +- What uncertainty remains after the probe and how is it being managed? + +### Sequencing + +- Which tasks unlock the most downstream work? +- Which tasks should never run in parallel? +- Where should contract stabilization happen first? + +## 15.3 Breakdown framework + +- Can this task be completed in 1-3 days? +- Is ownership explicit? +- Are acceptance criteria binary and testable? +- Are dependencies minimal and explicit? +- Are conflict hotspots identified? + +## 15.4 Execution framework + +- What changed since the last sync that matters? +- What blocker has highest schedule risk? +- Are we optimizing local progress or total throughput? +- Is current sequencing still valid? +- Is context current enough for handoff right now? + +## 15.5 Quality and release framework + +- Which failure mode is most expensive in production? +- Is that failure mode directly tested? +- Are non-functional requirements covered (performance, reliability, security)? +- Is rollback confidence explicit and realistic? +- What evidence supports release readiness? + +## 15.6 Retrospective framework + +- Where did avoidable rework occur? +- Which decision was made too late? +- Which rule should be added or tightened? +- Which template or script would reduce repeat friction? +- What do we stop doing next cycle? + +--- + +## 16) Role operating playbooks + +### 16.1 PM playbook + +#### Weekly cadence + +1. review outcome alignment across active projects +2. review spec quality and scope boundaries +3. review blocker ownership and dependency health +4. review delivery confidence and release risk + +#### Stage-specific control points + +- Discovery: approve success metrics and non-goals +- Discovery: approve success metrics, non-goals, and the probe decision +- Planning: validate outcome-to-plan alignment and post-probe changes +- Breakdown: reject ambiguous acceptance criteria +- Synchronization: confirm cross-tool parity for active scope +- Closeout: require outcome review, not only output completion + +#### Daily hygiene + +- review `progress.md` +- review blocker queue +- confirm any major priority shifts are documented + +### 16.2 Tech lead playbook + +#### Daily cadence + +1. architecture and decomposition quality check +2. stream boundary and ownership check +3. blocker triage and re-sequencing decisions +4. quality gate readiness checks + +#### Stage-specific control points + +- Planning: approve architecture tradeoffs, reversibility notes, and probe adequacy +- Breakdown: validate dependency graph and conflict zones +- Execution: enforce stream discipline and integration points +- Quality: enforce test depth by risk tier +- Merge: enforce closure criteria before approval + +#### Weekly hygiene + +- review reopen rate and root causes +- review sync drift incidents +- review context debt and decision log quality + +### 16.3 Engineer / agent operator playbook + +#### Daily cadence + +1. pick dependency-safe task from ready queue +2. execute within stream scope +3. update evidence, status, and probe findings continuously when applicable +4. run required quality checks before handoff + +#### Stage-specific control points + +- Start: verify task is truly ready +- During execution: escalate conflicts early +- Before review: verify acceptance and evidence completeness +- Before close: verify sync and quality parity + +#### Non-negotiable behavior + +- do not start blocked work as if it were ready +- do not close without evidence +- do not bypass required gates silently + +--- + +## 17) Templates and operational checklists + +### 17.1 Spec template (`spec.md`) + +```markdown +--- +name: +slug: +owner: +status: planned +created: +updated: +outcome: +uncertainty: +probe_required: +probe_status: +--- + +# Spec: + +## Executive Summary + +## Problem and Users + +## Outcome and Success Metrics + +## Scope +### In Scope +### Out of Scope + +## Functional Requirements + +## Non-Functional Requirements + +## Hypotheses and Unknowns + +## Touchpoints to Exercise + +## Probe Findings + +## Footguns Discovered + +## Remaining Unknowns + +## Dependencies + +## Approval Notes +``` + +### 17.2 Delivery plan template (`plan.md`) + +```markdown +--- +name: +status: planned +lead: +created: +updated: +linear_project_id: +risk_level: +spec_status_at_plan_time: +--- + +# Delivery Plan: + +## What Changed After Probe + +## Architecture Decisions + +## Probe-Driven Architecture Changes + +## Workstream Design + +## Milestone Strategy + +## Rollout Strategy + +## Test Strategy + +## Rollback Strategy + +## Remaining Delivery Risks +``` + +### 17.3 Workstream template + +```markdown +--- +name: WS-A API Foundation +owner: backend-team +status: planned +created: +updated: +--- + +# Workstream: WS-A API Foundation + +## Objective + +## Owned Files/Areas + +## Dependencies + +## Risks + +## Handoff Criteria +``` + +### 17.4 Task template + +```markdown +--- +id: T-001 +name: +status: ready +workstream: WS-A +created: +updated: +linear_issue_id: +github_issue: +github_pr: +depends_on: [] +conflicts_with: [] +parallel: true +priority: medium +estimate: M +--- + +# Task: + +## Description + +## Acceptance Criteria +- [ ] + +## Technical Notes + +## Definition of Done +- [ ] Implementation complete +- [ ] Tests pass +- [ ] Review complete +- [ ] Docs updated + +## Evidence Log +- : +``` + +### 17.5 Progress update template + +```markdown +--- +timestamp: +status: in-progress|blocked|review +task: +stream: +--- + +# Progress Update + +## Completed +- + +## In Progress +- + +## Blockers +- None / + +## Next Actions +- +``` + +### 17.6 Completion comment template + +```markdown +# Completion Summary + +## Acceptance Criteria +- ✅ +- ✅ + +## Deliverables +- + +## Quality Evidence +- Unit tests: ✅ +- Integration tests: ✅ +- GUI tests: ✅/N/A + +## Notes +- +``` + +### 17.7 Operational checklists + +#### Decomposition checklist + +- [ ] each task is atomic +- [ ] each task has owner and estimate +- [ ] dependency graph acyclic +- [ ] conflict hotspots explicit +- [ ] stream ownership boundaries clear + +#### Sync checklist + +- [ ] all active tasks mapped +- [ ] status parity verified +- [ ] dependency parity verified +- [ ] orphan drift check complete + +#### Closeout checklist + +- [ ] required tasks resolved +- [ ] quality gates passed +- [ ] evidence complete +- [ ] retrospective updated + +--- + +## 18) Migration playbook (existing Delano repos) + +This section covers migration from older layouts such as: + +- `.project/prds/` +- `.project/epics//epic.md` +- numbered task files under epic folders + +### 18.1 Migration goals + +- preserve historical artifacts +- avoid destructive restructuring +- establish new canonical path for future work + +### 18.2 Non-destructive migration strategy + +1. keep existing folders intact +2. create new canonical structure under `.project/projects//` +3. normalize runtime references so `.agents` is canonical and `.claude` remains compatibility-only +4. map old PRD/Epic/Task artifacts into Spec/Plan/Task contracts +5. maintain old-to-new references in a migration index file + +### 18.3 Step-by-step migration + +#### Step 1: inventory + +- list all PRDs and epics +- capture active statuses and linked issue ids + +#### Step 2: create new project folders + +For each active epic scope: + +- create `.project/projects//` +- create `spec.md`, `plan.md`, `tasks/`, `workstreams/`, `updates/` + +#### Step 3: map legacy artifacts + +- legacy PRD content -> `spec.md` with uncertainty and probe fields populated +- legacy epic content -> `plan.md` with probe delta and risk fields populated +- legacy task files -> `tasks/*.md` with preserved IDs and links + +#### Step 4: map statuses + +- `open` -> `ready` when executable, or `deferred` when not actionable in the current delivery scope +- `in-progress` -> `in-progress` +- `closed` -> `done` + +#### Step 5: mapping registry update + +Add migration mapping to: + +- `.project/registry/linear-map.json` +- `.project/registry/migration-map.json` (recommended) + +#### Step 6: validation and dry-run sync + +Run `bash .agents/scripts/pm/validate.sh` and a dry-run sync before mutating remote state. + +### 18.4 Migration acceptance criteria + +- no active task is lost +- all active mappings preserved +- status parity maintained +- old artifacts remain readable for audit + +### 18.5 Sunset policy for legacy folders + +After two stable cycles: + +- mark legacy folders as archived-readonly +- keep pointers to canonical project folders +- do not delete legacy content without explicit decision + +--- + +## 19) Adoption roadmap and maturity gates + +### Phase 1: Contract hardening (1-2 weeks) + +- finalize schemas and validation checks +- enforce frontmatter and dependency rules + +Gate: + +- zero critical contract violations on active work + +### Phase 2: Sync reliability (1-2 weeks) + +- operationalize idempotent sync cycle +- validate drift class handling + +Gate: + +- two end-to-end dry runs with no unresolved drift + +### Phase 3: Parallel maturity (2-3 weeks) + +- standardize stream contracts +- validate conflict escalation workflow in real delivery + +Gate: + +- one multi-stream delivery completed without uncontrolled merge conflict + +### Phase 4: Operational excellence (ongoing) + +- tighten risk-based quality gates +- add telemetry dashboards from logs +- improve templates and scripts from retrospectives + +Gate: + +- measurable reduction in reopen rate and sync incidents over two cycles + +--- + +## Final note + +The architecture is mature enough to run. + +Sustainable performance depends on execution discipline in four areas: + +1. decomposition quality +2. synchronization discipline +3. evidence-backed closure +4. regular context maintenance + +When these remain strong, Delano can run fast across coding agents without losing control. diff --git a/README.md b/README.md index 97ca4ff..52d0abe 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,14 @@ WebsiteQuick StartChrome Extension • + Firefox / Zen Buildnpm

License: MIT Chrome Web Store + Firefox and Zen beta build npm PRs Welcome

@@ -89,12 +91,27 @@ You can also use Pointa to... ### Prerequisites - Node.js 18+ -- A Chromium-based browser (Chrome, Edge, Brave, etc.) +- A supported browser: + - Chrome, Edge, Brave, or another Chromium-based browser + - Firefox or Zen for local beta/development builds - An AI coding agent that supports MCP (Cursor, Claude Code, Windsurf, etc.) ### 1. Install the browser extension -Install from the [Chrome Web Store](https://chromewebstore.google.com/detail/pointa/chfdkemckcihigkepbnpegcopkncoane) (recommended), or [load unpacked](docs/DEVELOPMENT.md) for development. +Install the Chrome build from the [Chrome Web Store](https://chromewebstore.google.com/detail/pointa/chfdkemckcihigkepbnpegcopkncoane) (recommended), or [load unpacked](docs/DEVELOPMENT.md) for local Chrome development. + +Firefox and Zen support is available as a local beta/development build while AMO +release work is in progress: + +```bash +npm install +npm run firefox:build +``` + +Then open `about:debugging#/runtime/this-firefox`, choose **Load Temporary +Add-on...**, and select `dist/firefox/manifest.json`. Start `pointa-server` +through your MCP configuration or with `cd annotations-server && npm run dev` +before annotating localhost pages. ### 2. Connect your AI coding agent @@ -121,6 +138,11 @@ This automatically installs the server, starts the HTTP daemon for the extension 4. Add your feedback 5. Ask your AI agent to "implement the Pointa annotations" +In Firefox and Zen, annotation CRUD, element-linked screenshots, +console/error/network evidence, and backend logs use the shared Pointa server +flow. Responsive viewport capture is hidden because Firefox does not expose the +required viewport-emulation capability to this package. + ## AI Agent Setup
@@ -325,12 +347,16 @@ If you use the `npx` approach from Quick Start, the server is managed automatica **Known limitations** - Elements inside Shadow DOM (Web Components) cannot be annotated - Designed for localhost/local domains only -- Currently supports Chromium-based browsers only (Chrome, Edge, Brave) +- Chrome and Chromium-based browsers are supported through the Chrome package +- Firefox and Zen support is available through the generated beta package; CDP-only capture features are unavailable there
Uninstalling -**Remove the extension:** Go to `chrome://extensions/` and remove Pointa +**Remove the Chrome extension:** Go to `chrome://extensions/` and remove Pointa + +**Remove the Firefox/Zen build:** Go to `about:debugging#/runtime/this-firefox` +or your browser's add-ons page and remove Pointa **Uninstall the server:** ```bash diff --git a/annotations-server/README.md b/annotations-server/README.md index cb6e1e1..eb2bbd2 100644 --- a/annotations-server/README.md +++ b/annotations-server/README.md @@ -1,6 +1,6 @@ # pointa-server -MCP server for [Pointa](https://chromewebstore.google.com/detail/pointa/chfdkemckcihigkepbnpegcopkncoane) — a Chrome extension that lets you leave visual annotations on your localhost for AI coding agents to pick up and implement. +MCP server for [Pointa](https://chromewebstore.google.com/detail/pointa/chfdkemckcihigkepbnpegcopkncoane) — a browser extension for Chrome, Firefox, and Zen that lets you leave visual annotations on your localhost for AI coding agents to pick up and implement. Point at UI issues, add comments, and your AI agent sees exactly what you see. @@ -59,7 +59,7 @@ export POINTA_PORT=4243 ``` **Note:** If you change the port, you'll also need to update: -1. Chrome extension settings (to connect to the new port) +1. Pointa extension settings (to connect to the new port) 2. Any custom configurations pointing to the server --- @@ -292,7 +292,7 @@ This tool solves a common problem when using Linear's MCP server: attachment URL The server provides: - **SSE Endpoint** (`/sse`): For AI coding agent MCP connections -- **HTTP API** (`/api/annotations`): For Chrome extension communication +- **HTTP API** (`/api/annotations`): For browser extension communication - **Image Upload** (`/api/upload-image`): For uploading annotation images - **Health Check** (`/health`): For status monitoring @@ -316,4 +316,4 @@ npm run dev ## License -MIT \ No newline at end of file +MIT diff --git a/annotations-server/USER_EXPERIENCE_FLOWS.md b/annotations-server/USER_EXPERIENCE_FLOWS.md index 8c4ed45..c886a95 100644 --- a/annotations-server/USER_EXPERIENCE_FLOWS.md +++ b/annotations-server/USER_EXPERIENCE_FLOWS.md @@ -7,7 +7,7 @@ This document covers all user experience flows and edge cases for the Pointa MCP ### Flow 1: Fresh User - Cursor Auto-Start (Recommended) **Steps:** -1. User installs Pointa Chrome extension +1. User installs the Pointa browser extension 2. User adds config to Cursor's mcp.json: ```json { @@ -26,11 +26,11 @@ This document covers all user experience flows and edge cases for the Pointa MCP - Bridge starts daemon in background on port 4242 - Bridge forwards stdio ↔ HTTP to daemon - Cursor connects successfully ✅ -- Chrome extension connects to daemon on port 4242 ✅ +- Browser extension connects to daemon on port 4242 ✅ **User sees:** - Cursor: MCP tools available (read_annotations, etc.) -- Chrome extension: "Server online" indicator +- Browser extension: "Server online" indicator - Everything just works! --- @@ -38,14 +38,14 @@ This document covers all user experience flows and edge cases for the Pointa MCP ### Flow 2: Fresh User - Manual Start **Steps:** -1. User installs Pointa Chrome extension +1. User installs the Pointa browser extension 2. User runs: `npx pointa-server start` 3. User adds URL config to Cursor **What happens:** - Command downloads pointa-server (first time) - Daemon starts on port 4242 -- Chrome extension connects ✅ +- Browser extension connects ✅ - User manually adds Cursor config - Cursor connects via HTTP ✅ @@ -64,13 +64,13 @@ This document covers all user experience flows and edge cases for the Pointa MCP **During work:** - User uses Cursor → Stdio MCP works ✅ -- User browses web → Chrome extension (HTTP) works ✅ +- User browses web → browser extension (HTTP) works ✅ - Both share same data files (annotations.json) **Evening:** - User closes Cursor - Stdio process dies (attached to Cursor) -- HTTP daemon keeps running (for Chrome extension) ✅ +- HTTP daemon keeps running (for browser extension) ✅ --- @@ -102,13 +102,13 @@ This document covers all user experience flows and edge cases for the Pointa MCP **Scenario:** - HTTP daemon crashes (OOM, bug, etc.) - Stdio process is still running (for MCP) -- Chrome extension can't connect +- Browser extension can't connect **What happens:** - **Cursor MCP still works!** ✅ (stdio process independent) -- Chrome extension shows "offline" ❌ +- Browser extension shows "offline" ❌ -**Recovery for Chrome extension:** +**Recovery for browser extension:** - User runs: `npx pointa-server start` - Or closes/reopens Cursor (auto-restarts daemon) - Everything works again @@ -169,7 +169,7 @@ No-op, helpful message ✅ - CLI times out waiting for daemon - **Cursor shows MCP connection failed** ✅ -**BUT:** If you only use Cursor MCP (not Chrome extension), you could skip HTTP entirely in the future. +**BUT:** If you only use Cursor MCP (not the browser extension), you could skip HTTP entirely in the future. **User action:** 1. Check what's using port: `lsof -i :4242` @@ -178,10 +178,10 @@ No-op, helpful message ✅ --- -### Edge Case 5: Chrome Extension Can't Connect +### Edge Case 5: Browser Extension Can't Connect **Scenario:** -- Chrome extension shows "Server offline" +- Browser extension shows "Server offline" - But user has Cursor open with auto-start **Diagnosis:** @@ -257,7 +257,7 @@ npx pointa-server status ### Edge Case 8: Concurrent Writes to annotations.json **Scenario:** -- Chrome extension writes annotation +- Browser extension writes annotation - MCP reads annotations at same moment - Potential race condition @@ -298,7 +298,7 @@ npx pointa-server status **What happens:** - Cursor detects MCP connection lost - Shows "MCP server disconnected" -- HTTP daemon keeps running (for Chrome extension) ✅ +- HTTP daemon keeps running (for browser extension) ✅ **Recovery:** - Cursor restarts MCP connection @@ -347,7 +347,7 @@ Then add URL to mcp.json - ✅ Auto-starts daemon - ✅ Errors visible in Cursor - ✅ Auto-recovery (reopen Cursor) -- ✅ Works with Chrome extension +- ✅ Works with browser extension - ✅ Multiple Cursor windows supported - ✅ Uses official MCP SDK (StdioServerTransport) - ✅ Standard, well-supported approach @@ -390,7 +390,7 @@ Then add URL to mcp.json │ │ │ • HTTP server on :4242 │ │ • /api/annotations endpoint │ - │ • Serves Chrome extension │ + │ • Serves browser extension │ │ • Persistent, stays running │ └────────┬────────────────────────────┘ │ @@ -408,12 +408,12 @@ Then add URL to mcp.json ▲ │ HTTP :4242/api ┌────────┴────────────────────────────┐ - │ Chrome Extension │ + │ Browser Extension │ └─────────────────────────────────────┘ ``` **Key points:** -- **Two separate processes:** Stdio (for Cursor MCP) + HTTP Daemon (for Chrome extension) +- **Two separate processes:** Stdio (for Cursor MCP) + HTTP Daemon (for browser extension) - **Both use official MCP SDK:** No custom protocol hacks ✅ - **Shared data files:** Both read/write same JSON files - **Clean separation:** Stdio dies with Cursor, daemon persists @@ -426,7 +426,7 @@ Then add URL to mcp.json **What's logged:** - Daemon startup/shutdown -- API requests (Chrome extension) +- API requests (browser extension) - MCP tool calls - Errors and warnings @@ -456,7 +456,7 @@ npx pointa-server restart --- -### Chrome Extension Shows "Offline" +### Browser Extension Shows "Offline" **Check:** ```bash @@ -493,7 +493,7 @@ npx pointa-server start ✅ **Zero-friction setup** - No installation, just add config ✅ **Visible errors** - Cursor shows if MCP fails ✅ **Auto-recovery** - Reopen Cursor to fix issues -✅ **Persistent daemon** - Chrome extension works independently +✅ **Persistent daemon** - Browser extension works independently ✅ **Shared state** - All clients see same data ✅ **No port conflicts** - Multiple Cursor windows work ✅ **Better UX** - "Set and forget" experience @@ -502,4 +502,3 @@ npx pointa-server start - Manual control option (start/stop/restart commands) - Simple architecture (just adds lightweight bridge) - Backward compatibility (URL config still works) - diff --git a/annotations-server/lib/server.js b/annotations-server/lib/server.js index d12490e..22618f0 100755 --- a/annotations-server/lib/server.js +++ b/annotations-server/lib/server.js @@ -96,8 +96,9 @@ class LocalAnnotationsServer { // Allow requests with no origin (like mobile apps or curl requests) if (!origin) return callback(null, true); - // Allow Chrome extension origins - if (origin.startsWith('chrome-extension://')) { + // Allow browser extension origins + if (origin.startsWith('chrome-extension://') || + origin.startsWith('moz-extension://')) { return callback(null, true); } @@ -326,18 +327,12 @@ class LocalAnnotationsServer { try { const { id } = req.params; - const annotations = await this.loadAnnotations(); - const index = annotations.findIndex((a) => a.id === id); + const deletedAnnotation = await this.deleteAnnotationById(id); - if (index === -1) { + if (!deletedAnnotation) { return res.status(404).json({ error: 'Annotation not found' }); } - const deletedAnnotation = annotations[index]; - annotations.splice(index, 1); - - await this.saveAnnotations(annotations); - // Also delete associated images if they exist const imageDir = path.join(IMAGES_DIR, id); if (existsSync(imageDir)) { @@ -1879,13 +1874,19 @@ class LocalAnnotationsServer { async saveAnnotations(annotations) { // Serialize all save operations to prevent race conditions - this.saveLock = this.saveLock.then(async () => { + this.saveLock = this.recoverSaveLock('annotations').then(async () => { return this._saveAnnotationsInternal(annotations); }); return this.saveLock; } + recoverSaveLock(label) { + return this.saveLock.catch((error) => { + console.warn(`Recovering ${label} save queue after failure:`, error.message); + }); + } + async _saveAnnotationsInternal(annotations) { // Move jsonData outside try block to make it accessible in catch @@ -1963,12 +1964,30 @@ class LocalAnnotationsServer { } async saveArchive(archive) { - this.saveLock = this.saveLock.then(async () => { + this.saveLock = this.recoverSaveLock('archive').then(async () => { return this._saveArchiveInternal(archive); }); return this.saveLock; } + async deleteAnnotationById(id) { + this.saveLock = this.recoverSaveLock('annotation delete').then(async () => { + const annotations = await this.loadAnnotations(); + const index = annotations.findIndex((a) => a.id === id); + + if (index === -1) { + return null; + } + + const deletedAnnotation = annotations[index]; + annotations.splice(index, 1); + await this._saveAnnotationsInternal(annotations); + return deletedAnnotation; + }); + + return this.saveLock; + } + async _saveArchiveInternal(archive) { const jsonData = JSON.stringify(archive, null, 2); @@ -2521,7 +2540,7 @@ class LocalAnnotationsServer { * @param {Array} ids - Array of issue report IDs to delete */ async deleteIssueReportsByIds(ids) { - this.issueReportsSaveLock = this.issueReportsSaveLock.then(async () => { + this.issueReportsSaveLock = this.recoverIssueReportsSaveLock('issue report delete').then(async () => { const issueReports = await this.loadBugReports(); const toDelete = issueReports.filter(r => ids.includes(r.id)); const remaining = issueReports.filter(r => !ids.includes(r.id)); @@ -2553,7 +2572,7 @@ class LocalAnnotationsServer { */ async deleteAnnotationsByIds(ids) { // Use save lock to prevent race conditions - this.saveLock = this.saveLock.then(async () => { + this.saveLock = this.recoverSaveLock('annotation bulk delete').then(async () => { const annotations = await this.loadAnnotations(); const toDelete = annotations.filter(a => ids.includes(a.id)); const remaining = annotations.filter(a => !ids.includes(a.id)); @@ -2615,13 +2634,19 @@ class LocalAnnotationsServer { async saveBugReports(issueReports) { // Serialize all save operations to prevent race conditions - this.issueReportsSaveLock = this.issueReportsSaveLock.then(async () => { + this.issueReportsSaveLock = this.recoverIssueReportsSaveLock('issue reports').then(async () => { return this._saveBugReportsInternal(issueReports); }); return this.issueReportsSaveLock; } + recoverIssueReportsSaveLock(label) { + return this.issueReportsSaveLock.catch((error) => { + console.warn(`Recovering ${label} save queue after failure:`, error.message); + }); + } + async _saveBugReportsInternal(issueReports) { const jsonData = JSON.stringify(issueReports, null, 2); @@ -2738,13 +2763,19 @@ class LocalAnnotationsServer { async saveInspirations(inspirations) { // Serialize all save operations to prevent race conditions - this.inspirationsSaveLock = this.inspirationsSaveLock.then(async () => { + this.inspirationsSaveLock = this.recoverInspirationsSaveLock('inspirations').then(async () => { return this._saveInspirationsInternal(inspirations); }); return this.inspirationsSaveLock; } + recoverInspirationsSaveLock(label) { + return this.inspirationsSaveLock.catch((error) => { + console.warn(`Recovering ${label} save queue after failure:`, error.message); + }); + } + async _saveInspirationsInternal(inspirations) { const jsonData = JSON.stringify(inspirations, null, 2); @@ -2835,7 +2866,7 @@ class LocalAnnotationsServer { */ async applyAnnotationsUpdate(mutator) { // Chain onto saveLock to serialize read→mutate→save - this.saveLock = this.saveLock.then(async () => { + this.saveLock = this.recoverSaveLock('annotation update').then(async () => { const current = await this.loadAnnotations(); const result = await mutator(current); await this._saveAnnotationsInternal(current); diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f3efadd..885a651 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -24,7 +24,8 @@ pointa-app/ **Extension:** - Vanilla JavaScript (no frameworks) -- Chrome Extension Manifest V3 +- Chrome Extension Manifest V3 source package +- Generated Firefox/Zen package from the shared extension source - CSS with custom properties for theming **Server:** @@ -34,7 +35,10 @@ pointa-app/ ## Extension Development -### Initial Setup +The `extension/` directory is the shared source. Chrome loads it directly. +Firefox and Zen use a generated package in `dist/firefox/`. + +### Chrome Initial Setup 1. **Load the Extension in Chrome:** - Open Chrome and navigate to `chrome://extensions/` @@ -106,6 +110,7 @@ For faster iteration, you can: - [ ] Content scripts work on localhost pages - [ ] Background script functions properly - [ ] No console errors in DevTools +- [ ] Firefox/Zen beta package still builds when shared extension code changes ## Local Server Development @@ -140,4 +145,53 @@ Test on common localhost setups: 4. **Test the extension:** - Click the extension icon to open popup - Click elements on the page to create annotations - - Verify annotations are saved and displayed \ No newline at end of file + - Verify annotations are saved and displayed + +## Firefox Extension Development + +Firefox builds are generated from the shared `extension/` source into +`dist/firefox/`. The Chrome manifest remains the source for the Chrome package; +the Firefox build script writes a browser-specific manifest with Gecko settings, +Firefox background scripts, and without Chrome-only permissions. + +See `docs/FIREFOX_PORT.md` for the package architecture, lint baseline, and +release notes. + +```bash +npm run firefox:build # Generate dist/firefox/ +npm run firefox:lint # Build, then run web-ext lint against dist/firefox/ +npm run firefox:run # Build, then launch Firefox with the generated add-on +npm run firefox:package # Build an unsigned Firefox package under dist/firefox-artifacts/ +``` + +Current expected lint baseline during the port: + +- `firefox:lint` must have zero errors. +- Static warnings for `chrome.debugger` calls are expected in the generated + Firefox build, but runtime code gates these paths behind capability checks. +- `innerHTML` warnings are tracked in the AMO-readiness audit before public + Firefox submission. + +Firefox uses visible-tab screenshots and page-level console/error/network +instrumentation where Chrome can use browser-level debugging APIs. Responsive +viewport capture is hidden in Firefox because the generated package cannot +emulate viewport sizes there. See `docs/FIREFOX_EVIDENCE_CAPTURE.md` for the +Available, Approximate, and Unavailable evidence matrix. + +When testing locally, start the Pointa server first: + +```bash +cd annotations-server +npm run dev +``` + +Then run the Firefox add-on and test a localhost page, for example: + +```bash +python3 -m http.server 8080 +npm run firefox:run +``` + +Open `http://localhost:8080/testing/demo-app/index.html` in the launched Firefox +profile and verify toolbar/sidebar injection, annotation creation, screenshot +capture, and server offline/online behavior. diff --git a/docs/FIREFOX_AMO_INNERHTML_AUDIT.md b/docs/FIREFOX_AMO_INNERHTML_AUDIT.md new file mode 100644 index 0000000..023ce17 --- /dev/null +++ b/docs/FIREFOX_AMO_INNERHTML_AUDIT.md @@ -0,0 +1,97 @@ +# Firefox AMO innerHTML Audit + +Date: 2026-05-29 + +Command run: + +```powershell +npm run firefox:lint +``` + +Result: `web-ext lint` reported `0` errors, `0` notices, and `46` warnings. This audit covers the `26` `UNSAFE_VAR_ASSIGNMENT` warnings for `innerHTML`; the other `20` warnings are Firefox `debugger` API support warnings. + +Follow-up hardening already applied in this workstream: + +- annotation create/edit modals now escape page-derived selectors and stored + annotation comments before template insertion; +- bug replay success/failure modals now escape prompt attribute values, replay + error messages, and page-derived replay steps; +- bug/performance report IDs and several timeline/resource values now escape + dynamic text before rendering; +- `PointaUtils.escapeHtml()` now defensively stringifies nullish input and + `PointaUtils.escapeAttribute()` covers quoted attribute values. + +These changes reduce risk but do not remove the lint warnings. AMO submission +should still treat the release-blocker/deferred rows below as requiring either +DOM-node refactors or an explicit review waiver. + +## High-Level Disposition + +AMO submission should not proceed with the current `innerHTML` warnings unaddressed. Several are static UI shells and likely acceptable for internal builds, but multiple warning paths render user-, page-, or server-provided strings into HTML templates. Those should be refactored before AMO review. + +Recommended refactor pattern: keep static shell markup small, then assign dynamic values using `textContent`, `value`, `dataset`, `classList` with allowlists, and `style` properties with validation. Avoid putting dynamic strings inside template literals that are assigned to `innerHTML`, even when an existing escape helper is used. + +## Warning Disposition + +| File / lint line | Disposition | Notes and required action | +| --- | --- | --- | +| `content/content.js:646` | release blocker/deferred | Edit annotation modal interpolates `context.selector`, viewport/position values, and `annotation.comment` inside an `innerHTML` template. `annotation.comment` is user/server data and `context.selector` is page-derived. Refactor before AMO; set textarea `.value` and selector `.textContent` after shell creation. | +| `content/content.js:749` | release blocker/deferred | New annotation modal interpolates `context.selector` and page-derived element geometry into an `innerHTML` template. Geometry is low-risk numeric data, but selector text is page-derived and should be assigned via `textContent`. | +| `content/modules/bug-replay-engine.js:212` | safe static template | Replay progress overlay uses static copy plus `totalSteps`. Coerce numeric count and assign with `textContent` if zero-warning AMO cleanup is pursued. | +| `content/modules/bug-replay-engine.js:256` | refactor-needed | Success modal puts `bugId` into `promptText`, then into an ``. Bug IDs are generated locally/server-side but should still be assigned with `.value`. | +| `content/modules/bug-replay-engine.js:297` | release blocker/deferred | Replay failure modal renders `error.message` and `originalSteps`. `originalSteps` are built from recorded page element text/IDs/classes, so this is page-provided content. Refactor before AMO. | +| `content/modules/bug-report-ui.js:129` | release blocker/deferred | Timeline review modal inserts `timelineHTML` and key issue rows. Some fields are escaped, but event severity/class tokens, keypress values, network methods/URLs/status/errors, and generated timeline HTML include page/runtime data. Refactor with DOM nodes and allowlisted class names. | +| `content/modules/bug-report-ui.js:339` | safe static template | Bug report form renders static UI and numeric summary counts/booleans from recording data. Low security risk for internal builds; still needs DOM/text assignment to remove AMO warning. | +| `content/modules/bug-report-ui.js:407` | refactor-needed | Confirmation modal renders `bugReportId` through `formatBugId()`, which returns HTML including the raw ID. IDs are expected generated values but should be split into text nodes and a static date span. | +| `content/modules/bug-report-ui.js:791` | escaped/text assignment already used | `escapeHtml()` creates a temporary div, assigns `textContent`, and returns `div.innerHTML`. This warning is a false positive on the helper pattern, but the helper still contributes to AMO warning count. | +| `content/modules/design-mode.js:951` | safe static template | Success overlay currently receives only static local messages from known call sites. Use `textContent` if this ever accepts dynamic messages or for zero-warning AMO cleanup. | +| `content/modules/floating-toolbar.js:72` | safe static template | Toolbar shell is static apart from internal theme/status values and extension icon URL. Likely acceptable for internal builds. | +| `content/modules/floating-toolbar.js:327` | release blocker/deferred | Panel container receives HTML from `ToolbarPanels.buildPanel()`. That path includes annotations, report IDs, page URLs, annotation status/classes, and design preview data. Treat this sink as AMO-blocking until `ToolbarPanels` builds DOM nodes or fully constrains dynamic fields. | +| `content/modules/floating-toolbar.js:580` | release blocker/deferred | Same panel refresh sink as line 327, used when active panel content is rebuilt. Refactor together with `ToolbarPanels.buildPanel()`. | +| `content/modules/onboarding-overlay.js:32` | safe static template | Initial onboarding step uses static project-controlled markup. No user/page/server strings found. | +| `content/modules/onboarding-overlay.js:89` | safe static template | Step replacement uses static project-controlled markup selected by numeric step index. | +| `content/modules/onboarding-overlay.js:193` | safe static template | Agent instructions are selected from a hard-coded map. `mcpHttpUrl` comes from the canonical local helper. Likely acceptable for internal builds. | +| `content/modules/onboarding-overlay.js:840` | safe static template | Copy button restores `originalText` captured from the same static onboarding button template. Low risk. | +| `content/modules/performance-report-ui.js:153` | release blocker/deferred | Performance dashboard inserts generated device/resource/insight/interaction HTML. Some text is escaped, but resource `type`, resource URL/name, duration values, connection data, and class tokens should be validated/assigned as text. | +| `content/modules/performance-report-ui.js:494` | refactor-needed | Confirmation modal renders `perfReportId` through `formatPerfId()`, which returns HTML including the raw ID. Use text nodes plus a static date span. | +| `content/modules/performance-report-ui.js:790` | escaped/text assignment already used | Same safe helper pattern as `BugReportUI.escapeHtml()`: assigns `textContent` then reads `innerHTML`. False positive, but still a warning. | +| `content/modules/report-details.js:50` | release blocker/deferred | Bug report details modal renders server/local report data: `bugReport.id`, screenshot IDs/paths, status sections, key issues, timeline HTML, event methods/URLs/status/errors, and keypress data. Some fields are escaped, but not all. Refactor before AMO. | +| `content/modules/toolbar-panels.js:757` | refactor-needed | Inline delete confirmation puts `annotationId` into a `data-annotation-id` attribute through a template. Assign `dataset.annotationId` after creating the button. | +| `content/modules/toolbar-panels.js:1030` | safe static template | Page delete confirmation uses static text plus `annotationIds.length`. Coerce/assign count with `textContent` during zero-warning cleanup. | +| `content/modules/design-editor-ui.js:178` | release blocker/deferred | Design editor includes `scopeOptionsHTML`; labels can include `scopeInfo.componentName`, `containerName`, and `parentTag`, which are derived from the inspected page. `element.textContent` is escaped, but scope labels are not. Refactor before AMO. | +| `content/modules/inspiration-mode.js:732` | release blocker/deferred | Metadata panel renders page-derived `tagName`, `className`, computed CSS values, pseudo-state values, and color swatch style strings. Use text assignment and validated style properties. | +| `content/modules/inspiration-mode.js:1396` | safe static template | Action panel is static UI. Responsive breakpoint labels are extracted with a numeric `px` regex and selected states are fixed labels. Low risk for internal builds. | +| `content/modules/inspiration-mode.js:2187` | safe static template | Responsive capture progress modal is static shell plus internal step/progress labels. | +| `content/modules/inspiration-mode.js:2224` | safe static template | Responsive progress update uses internal breakpoint labels and numeric counters. Use text nodes to remove warning. | + +## AMO-Blocking Paths + +These paths appear to render user-, page-, or server-provided strings through `innerHTML` and should be changed before AMO submission: + +- `content/content.js`: annotation comments and page-derived selectors. +- `content/modules/design-editor-ui.js`: page-derived scope labels. +- `content/modules/inspiration-mode.js`: page-derived class names and computed CSS metadata. +- `content/modules/floating-toolbar.js` plus `content/modules/toolbar-panels.js`: annotation/report/panel HTML sink, including annotation IDs, URLs, statuses, comments, and design previews. +- `content/modules/report-details.js`: stored bug report details, timeline data, URLs, IDs, and server/action notes. +- `content/modules/bug-report-ui.js`: timeline/review data and report IDs. +- `content/modules/performance-report-ui.js`: performance resources, URLs, report IDs, and dashboard data. +- `content/modules/bug-replay-engine.js`: replay errors and page-derived step descriptions. + +## Likely Acceptable For Internal Builds + +These warning areas are static UI shells, numeric-only interpolation, icon swaps, or controlled setup content. They are still warnings, but they do not appear to carry untrusted strings today: + +- `content/modules/onboarding-overlay.js` +- `content/modules/floating-toolbar.js:72` +- `content/modules/design-mode.js:951` +- `content/modules/inspiration-mode.js:1396`, `:2187`, `:2224` +- `content/modules/bug-report-ui.js:339` +- `content/modules/toolbar-panels.js:1030` +- `content/modules/bug-replay-engine.js:212` + +## Cleanup Order + +1. Refactor AMO-blocking modals and panels first: `content/content.js`, `report-details.js`, `bug-report-ui.js`, `performance-report-ui.js`, `design-editor-ui.js`, and `inspiration-mode.js`. +2. Change `ToolbarPanels.buildPanel()` and `FloatingToolbar` panel rendering so panel content is built as DOM nodes/fragments instead of HTML strings. +3. Replace helper false positives (`escapeHtml()` returning `div.innerHTML`) with a shared `appendText()`/DOM builder pattern where practical. +4. Convert static shell warnings last if the goal is a zero-warning AMO lint run. diff --git a/docs/FIREFOX_DESIGN_INSPIRATION_COMPAT.md b/docs/FIREFOX_DESIGN_INSPIRATION_COMPAT.md new file mode 100644 index 0000000..11b0af5 --- /dev/null +++ b/docs/FIREFOX_DESIGN_INSPIRATION_COMPAT.md @@ -0,0 +1,46 @@ +# Firefox Design and Inspiration Compatibility + +Date: 2026-05-29 + +## Status + +Design annotations are supported in the Firefox package through the shared +annotation flow. The generated package injects `design-mode.js` and +`design-editor-ui.js`, saves `type: "design-edit"` annotations through the +background `saveAnnotation` action, and stores them through the existing +`/api/annotations` endpoint. + +Inspiration capture is supported for visible elements through the shared +visible-tab screenshot path. The generated package injects `inspiration-mode.js`, +uses the Firefox-visible screenshot fallback when responsive viewport emulation +is unavailable, stores screenshot files through `/api/inspiration-screenshots`, +and stores metadata through `/api/inspirations`. + +## Firefox Gaps + +- Responsive inspiration capture is unavailable in Firefox and the UI hides the + responsive breakpoint controls when the browser does not report viewport + emulation support. +- Inspiration screenshots are visible-viewport captures. Offscreen/full-page + element capture is not promised in Firefox. +- AMO-readiness still requires the `innerHTML` warning cleanup tracked in + `docs/FIREFOX_AMO_INNERHTML_AUDIT.md`; this is separate from local build + compatibility. + +## Evidence + +An isolated local server smoke was run with a temporary Pointa data directory: + +- saved a `design-edit` annotation through `/api/annotations`; +- read the saved design annotation back with its CSS changes intact; +- saved a PNG inspiration screenshot through `/api/inspiration-screenshots`; +- saved inspiration metadata through `/api/inspirations`; +- fetched the saved inspiration screenshot by filename. + +The smoke confirmed: + +- design annotation id: `firefox-t014-design`; +- inspiration id: `firefox-t014-inspiration`; +- screenshot path: `inspiration_screenshots/firefox-t014-inspiration-shot.png`; +- screenshot size: `68` bytes; +- responsive flag: `false`. diff --git a/docs/FIREFOX_EVIDENCE_CAPTURE.md b/docs/FIREFOX_EVIDENCE_CAPTURE.md new file mode 100644 index 0000000..5bd9bcc --- /dev/null +++ b/docs/FIREFOX_EVIDENCE_CAPTURE.md @@ -0,0 +1,185 @@ +# Firefox Evidence Capture + +Pointa's Chrome extension uses Chrome DevTools Protocol through +`chrome.debugger` for network, console, runtime, and responsive viewport evidence. +Firefox does not implement that API, so Firefox evidence capture must use +standard WebExtension capabilities and clear degraded states. + +## Capture Model + +Firefox issue reports should compose evidence from four supported channels: + +1. Page instrumentation +2. Extension-observed network metadata +3. Existing backend log bridge +4. Visible-tab screenshots + +Each event written to an issue timeline should include: + +- `timestamp` +- `relativeTime` +- `source`: `page`, `network`, `backend`, or `extension` +- `type` +- URL or route context when available +- payload fields specific to the event type + +## Page Instrumentation + +Use a page-world injected script when recording starts. The current console +recorder is a packaged MAIN-world script injected through `scripting.executeScript`. +It wraps `console.log`, `console.warn`, and `console.error`, then forwards +structured events to the content script with DOM events. +Recorder config is passed through a temporary `data-pointa-*` DOM attribute +before the packaged file is injected. Do not use MAIN-world `func` injection for +recorder config or stop commands; Firefox/Zen can surface that as CSP-blocked +`eval` on pages that omit `unsafe-eval`. +The same page-world recorder also listens for `error` and `unhandledrejection` +events and stores them as `console-error` timeline entries with `subtype: +page-error` or `subtype: unhandled-rejection` so existing issue UI and summary +logic continue to work. + +Supported event types: + +- `console-log` +- `console-info` +- `console-warn` +- `console-error` +- `page-error` +- `unhandled-rejection` + +Implementation notes: + +- Keep wrappers reversible; stopping recording should restore original console + methods and remove listeners. +- Capture only logs emitted after instrumentation starts. +- Serialize console arguments defensively to avoid throwing on cyclic objects. +- Do not persist raw objects directly; store concise stringified values and basic + structured metadata. +- Store timestamp, relative time, level, message, source URL, and severity. + +## Network Evidence + +Use reversible page `fetch`/`XMLHttpRequest` instrumentation while bug recording +is active as the no-CDP fallback. The recorder is injected as a packaged +MAIN-world script through `scripting.executeScript`; config and stop events are +bridged through temporary DOM attributes/events from the isolated extension +world to avoid Firefox CSP `eval` warnings. There is no inline DOM-script +fallback for Firefox because page CSP can block it and emit noisy warnings. Use +`webRequest` only if the Firefox permission decision later accepts the added +permission cost. +The target is request metadata and failure evidence, not response body capture. +Issue timelines keep the existing `type: network` shape with `subtype: success` +or `subtype: failed`. + +Supported event types: + +- `network-request` +- `network-response` +- `network-error` + +Minimum fields: + +- request URL +- method +- status code when available +- error reason when available +- request ID +- document URL or tab ID when available +- timing fields exposed by Firefox + +Response body capture is out of scope unless a supported, reviewable Firefox API +path is proven later. + +## Backend Logs + +Keep the existing `pointa-server dev` backend log channel. Firefox should call the +same server endpoints used by Chrome: + +- `/api/backend-logs/status` +- `/api/backend-logs/start` +- `/api/backend-logs/stop` +- `/api/backend-logs` + +Backend log events should keep their existing source labels and be merged into +issue timelines without browser-specific schema changes. + +## Screenshots + +Use `tabs.captureVisibleTab` for Firefox screenshots. Element-level screenshots +should crop the visible-tab image using content-side element geometry where +possible. +Firefox requires either `` or a current `activeTab` grant for +`tabs.captureVisibleTab`; the generated Firefox package requests `` so +screenshots still work from the persistent in-page toolbar after page reloads or +toolbar auto-reopen. + +Screenshot capture errors should be returned as structured, actionable failures +so annotation and report saves can continue without dropping non-image evidence. + +Supported: + +- visible viewport screenshots +- element crop from visible screenshot +- screenshot attachment upload through the existing server endpoint + +Firefox-created screenshot attachments use the existing Pointa image pipeline: +`/api/upload-image` stores files under `images/{annotationId}/...`, MCP +annotation reads expose `has_images`, `image_count`, and `image_paths`, and +`get_annotation_images` returns base64 data URLs for the saved files. + +Design annotations and inspiration captures use the same visible screenshot and +local server storage model. See `docs/FIREFOX_DESIGN_INSPIRATION_COMPAT.md` for +the current compatibility notes and validation evidence. + +Degraded or unavailable: + +- Chrome CDP full-page capture +- Chrome CDP responsive viewport emulation +- capture beyond the visible viewport without a future Firefox-specific approach + +## Parity Matrix + +Use these labels in Firefox QA notes and user-facing degraded-state copy: + +- **Available**: expected to work with the same user promise as Chrome. +- **Approximate**: captured with a Firefox-specific source and may miss browser-level data that CDP sees. +- **Unavailable**: not promised in Firefox until a supported implementation exists. + +| Evidence | Chrome Today | Firefox Implementation | Status | User-Facing Label | Implementation Notes | +| --- | --- | --- | --- | --- | --- | +| Visible screenshot | `tabs.captureVisibleTab` or CDP | `tabs.captureVisibleTab` with ``/`activeTab` | Available | Screenshot captured | Visible viewport only. | +| Element screenshot | capture plus crop | visible capture plus content-side element geometry crop | Available for visible elements | Element screenshot captured | Offscreen/full-page element capture is not promised. | +| Console methods | CDP Runtime/Log | packaged MAIN-world console wrapper | Approximate | Console logs captured while recording | Captures post-start `log`, `warn`, and `error`; page code can theoretically interfere with MAIN-world wrappers. | +| Runtime exceptions | CDP Runtime | MAIN-world `error` listener | Approximate | Runtime errors captured while recording | Source file, line, and column are included when Firefox exposes them. | +| Promise rejections | CDP Runtime | MAIN-world `unhandledrejection` listener | Approximate | Promise rejections captured while recording | Reason is stringified defensively. | +| Network metadata | CDP Network | packaged MAIN-world `fetch`/`XMLHttpRequest` wrapper | Approximate | Fetch/XHR requests captured while recording | Browser-level requests outside page fetch/XHR may be missed unless a future `webRequest` path is accepted. | +| Network response body | not a core promise | unavailable | Unavailable | Response body capture unavailable in Firefox | Do not imply request/response bodies are captured. | +| Backend logs | `pointa-server dev` | same local server bridge | Available | Backend logs captured when `pointa-server dev` is connected | Uses `/api/backend-logs/*` endpoints. | +| Responsive viewport | CDP Emulation | unavailable | Unavailable | Responsive capture unavailable in Firefox | Chrome debugger/CDP emulation has no Firefox equivalent in this package. | + +## Firefox UX Rules + +- Hide controls that depend on unsupported Firefox capabilities, such as + responsive viewport capture. +- Use user-facing limitation text such as "Responsive viewport capture is + unavailable in this browser" instead of protocol or debugger terminology. +- Keep Chrome-only controls visible when the Chrome package reports the + required capability. + +## Privacy and Permission Impact + +- Page instrumentation records local page console/error/network metadata only + while recording is active. +- `webRequest` requires explicit Firefox permissions and should be justified as + local development issue evidence. +- Backend logs are opt-in through `pointa-server dev`. +- Screenshots are user-initiated evidence attachments and remain local unless the + user exports or syncs them through another tool. + +## Follow-Up Tasks + +- T-016: implement console instrumentation. +- T-017: capture page errors and unhandled rejections. +- T-018: capture network metadata and failures. +- T-019: preserve backend log capture in Firefox issue reports. +- T-020: keep the parity/degraded-state matrix current after implementation. diff --git a/docs/FIREFOX_PORT.md b/docs/FIREFOX_PORT.md new file mode 100644 index 0000000..09b1eb9 --- /dev/null +++ b/docs/FIREFOX_PORT.md @@ -0,0 +1,145 @@ +# Firefox Port + +Pointa's Firefox package is generated from the shared Chrome extension source in +`extension/`. The generated output lives in `dist/firefox/` and is not the source +of truth. + +## Package Architecture + +- Shared source: `extension/` +- Chrome manifest: `extension/manifest.json` +- Firefox build script: `scripts/build-firefox-extension.js` +- Firefox output: `dist/firefox/` +- Firefox package artifacts: `dist/firefox-artifacts/` + +The build script copies the shared extension files, then writes a Firefox +manifest that: + +- uses `background.scripts` instead of the Chrome service worker declaration; +- includes `browser_specific_settings.gecko.id`; +- includes `browser_specific_settings.gecko.data_collection_permissions`; +- removes Chrome's top-level `privacy_policy` key because Firefox reports it as + an unexpected WebExtension manifest property; +- removes the unsupported `debugger` permission; +- removes Chrome's broad `tabs` permission because Firefox can read tab URL + metadata through matching host permissions or `activeTab`; +- preserves the Chrome manifest unchanged. + +## Background and Injection Runtime + +Firefox runs the generated MV3 package with `background.scripts`; Chrome keeps +using the shared source manifest's service worker. `extension/common/browser-compat.js` +is loaded before background initialization and before content modules so shared +capabilities, local-server URLs, and browser namespace behavior are available in +both runtimes. + +The action click path now checks unsupported page schemes before injection, +waits on one in-flight injection per tab, inserts `content/content.css`, and +injects content modules in a fixed order. Per-file markers make retries safe if +a page navigation or browser restriction interrupts the first injection attempt. +The Firefox build also appends a final `void 0` completion to generated content +and shared content-helper scripts. Firefox structured-clones `executeScript` +file results, so files ending with global class/object assignments can otherwise +abort injection with a non-structured-clonable result even when the script loaded +successfully. + +## Element Anchoring + +Firefox annotations use the same shared anchoring model as Chrome. Saved +annotations now include stable element attributes (`id`, test/data attributes, +ARIA labels, role/name/type, and similar signals), trimmed text, geometry, +sibling indexes, and parent context. Reload matching tries stable attributes +before text, class, or positional fallbacks, which reduces reliance on brittle +`nth-child` selectors after DOM changes. + +## Firefox Permission Scope + +The generated Firefox manifest currently keeps only these API permissions: + +| Permission | Reason | +| --- | --- | +| `activeTab` | Grants the clicked tab for action-triggered injection and browser-recognized extension actions. | +| `storage` | Stores onboarding, settings, and integration keys in extension-local storage. | +| `scripting` | Injects Pointa CSS and ordered content modules into supported pages. | + +Firefox host permissions include local-development hosts plus ``: + +```json +[ + "http://localhost/*", + "http://127.0.0.1/*", + "http://0.0.0.0/*", + "http://*.local/*", + "http://*.test/*", + "http://*.localhost/*", + "" +] +``` + +These patterns cover the pages Pointa annotates and the local `pointa-server` +API. `` is required for reliable persistent in-page screenshot capture +from the Pointa toolbar after navigation or toolbar auto-reopen, where the +temporary `activeTab` grant from the browser action may no longer be available. +MDN documents that `tabs.captureVisibleTab` requires `` or +`activeTab`, and `activeTab` only follows a qualifying extension user action. +MDN also documents that MV3 host permissions belong in +[`host_permissions`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/host_permissions), +and that matching host permissions can provide tab URL metadata without +[`tabs`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions). + +## Commands + +```bash +npm run firefox:build +npm run firefox:lint +npm run firefox:run +npm run firefox:package +``` + +`firefox:lint` and `firefox:package` both rebuild `dist/firefox/`. Do not run +them in parallel because they write the same generated directory. + +## Local Firefox Install + +For manual user testing without AMO signing: + +1. Run `npm install` if dependencies are not installed. +2. Run `npm run firefox:build`. +3. Open `about:debugging#/runtime/this-firefox` in Firefox. +4. Choose **Load Temporary Add-on...** and select `dist/firefox/manifest.json`. +5. Start `pointa-server` through MCP or run `cd annotations-server && npm run dev`. +6. Open a supported local page such as `http://localhost:3000` and click the + Pointa toolbar icon. + +Temporary add-ons are removed when the Firefox profile closes. Use +`npm run firefox:run` for repeated development sessions with a generated +testing profile. + +## Current Lint Baseline + +`npm run firefox:lint` currently exits with zero errors. Remaining warnings are +tracked below. + +| Warning | Owner | Disposition | +| --- | --- | --- | +| `UNSUPPORTED_API` for `chrome.debugger` calls in `background/background.js` | WS-002 / T-008 | Expected until debugger/CDP paths are capability-gated or replaced for Firefox. | +| `UNSAFE_VAR_ASSIGNMENT` for dynamic `innerHTML` writes in UI modules | WS-005 / T-025 | Audit before AMO submission; refactor or document each warning. | + +See `docs/FIREFOX_EVIDENCE_CAPTURE.md` for the Firefox replacement model for +console logs, page errors, network metadata, backend logs, screenshots, and +the QA/release parity labels for Available, Approximate, and Unavailable states. +See `docs/FIREFOX_RELEASE.md` for the current signing and distribution path. + +The resolved baseline errors were: + +- missing Firefox-compatible background fallback; +- missing Gecko add-on ID; +- invalid Firefox `debugger` manifest permission. + +## Release Notes + +The generated Firefox manifest currently declares `websiteActivity` and +`websiteContent` data collection permissions because Pointa records page +annotations, screenshots, page activity evidence, and related local development +context. See `docs/FIREFOX_PRIVACY_DECLARATION.md` for the privacy and +integration data-handling wording required before public submission. diff --git a/docs/FIREFOX_PRIVACY_DECLARATION.md b/docs/FIREFOX_PRIVACY_DECLARATION.md new file mode 100644 index 0000000..13254c6 --- /dev/null +++ b/docs/FIREFOX_PRIVACY_DECLARATION.md @@ -0,0 +1,113 @@ +# Firefox Privacy and Data Collection Declaration + +Date: 2026-05-29 + +## Manifest Declaration + +The generated Firefox manifest declares: + +```json +{ + "browser_specific_settings": { + "gecko": { + "data_collection_permissions": { + "required": ["websiteActivity", "websiteContent"], + "optional": [] + } + } + } +} +``` + +This matches the current Firefox package behavior. MDN documents +`browser_specific_settings.gecko.data_collection_permissions` as the manifest +location for Firefox extension data collection declarations for AMO submission. + +## Why These Categories Are Required + +Pointa's core feature is user-initiated local development feedback. To provide +that feature, the extension can collect or process: + +- website content selected by the user, including element selectors, text + snippets, style metadata, dimensions, and page URLs; +- website activity during recording, including user interactions, console + messages, runtime errors, promise rejections, fetch/XHR metadata, and failed + requests; +- screenshots attached to annotations, issue reports, performance reports, and + inspiration captures; +- backend logs only when the user starts a Pointa recording and the local + `pointa-server dev` log bridge is connected. + +The Firefox package requests `` for persistent visible-tab screenshot +capture from the in-page toolbar, but does not request Firefox `tabs` permission. +It also requests local-development host permissions, `activeTab`, `storage`, and +`scripting`. + +## Local-First Storage + +Pointa stores annotation and evidence data through the local `pointa-server` +running on `127.0.0.1:4242`. The server writes files under the user's local +`~/.pointa` directory, including: + +- `annotations.json`; +- `issue_reports.json`; +- `inspirations.json`; +- `images/{annotationId}/...`; +- `bug_screenshots/...`; +- `inspiration_screenshots/...`; +- `config.json` for local integration settings. + +Extension-local browser storage is used for onboarding, settings, and integration +state. Pointa does not require a Pointa-hosted cloud service for the Firefox +local development workflow. + +## AI Tool Handling + +Pointa exposes local data to AI coding tools through MCP only after the user +configures an AI tool to connect to `pointa-server`. AI tools can then read +annotations, image data, bug reports, performance reports, and related local +evidence through MCP tool calls. + +Data handling after an AI tool receives MCP results is controlled by that AI +tool and the user's configuration. AMO/release copy should tell users not to +connect Pointa to an AI tool unless they are comfortable sharing local annotation +and evidence content with that tool. + +## Linear Integration Handling + +Linear integration is optional. When configured, Pointa can use the stored Linear +API key from local `~/.pointa/config.json` to create or update Linear issues and +upload selected screenshots/debug artifacts to Linear. Pointa can also fetch +Linear attachment content through the `fetch_linear_attachment` MCP tool when +the user requests it and the local Linear key is available. + +Linear-bound data may include annotation text, selectors, page URLs, screenshots, +bug report JSON, console/error/network/backend evidence, and performance report +content. This data leaves the user's machine only when the user enables and uses +the Linear integration. + +## Listing Copy Requirements + +Firefox listing and privacy copy should state: + +- Pointa is local-first and designed for local development URLs. +- The Firefox package requests all-site host access so user-initiated screenshot + attachments keep working from the persistent in-page toolbar after navigation. +- Screenshots, annotations, logs, and page evidence are saved locally by default. +- Console/error/network evidence is captured only while a user-initiated + recording is active. +- Backend logs are captured only when `pointa-server dev` is connected and a + recording is active. +- MCP AI tools and Linear integrations can receive Pointa data only when the user + configures and uses those integrations. +- Responsive viewport capture is unavailable in Firefox; visible screenshot + capture is used instead. + +## Release Checks + +- `dist/firefox/manifest.json` must include `websiteActivity` and + `websiteContent` in required data collection permissions. +- `optional` data collection permissions must remain empty unless an optional + consent flow is added. +- Any future telemetry, cloud sync, or third-party export must update this file, + the manifest declaration, and AMO listing copy before release. diff --git a/docs/FIREFOX_QA_MATRIX.md b/docs/FIREFOX_QA_MATRIX.md new file mode 100644 index 0000000..4e47bf9 --- /dev/null +++ b/docs/FIREFOX_QA_MATRIX.md @@ -0,0 +1,103 @@ +# Firefox Manual QA Matrix + +Use this matrix for the current Firefox port. Firefox builds are generated into +`dist/firefox/`; rebuild them from shared source instead of editing generated +files. Use the evidence labels from `docs/FIREFOX_EVIDENCE_CAPTURE.md`: +`Available`, `Approximate`, and `Unavailable`. + +## Setup Commands + +From the repo root: + +```bash +npm install +npm run firefox:build +npm run firefox:lint +``` + +Manual Firefox load: + +```text +about:debugging#/runtime/this-firefox +Load Temporary Add-on... -> dist/firefox/manifest.json +``` + +Repeated development profile: + +```bash +npm run firefox:run +``` + +Pointa server: + +```bash +cd annotations-server +npm run dev +``` + +Optional demo fixture run: + +```bash +./scripts/load-demo.sh +python3 -m http.server 8080 +``` + +Default demo URL: + +```text +http://localhost:8080/testing/demo-app/index.html +``` + +Restore demo fixture data after testing: + +```bash +./scripts/clear-demo.sh +``` + +Do not run `npm run firefox:lint` and `npm run firefox:package` in parallel +because both rebuild `dist/firefox/`. + +## Run Record + +| Field | Value | +| --- | --- | +| Tester / date | | +| Git commit or build label | | +| Firefox version / channel | | +| Launch method | `npm run firefox:run` / temporary add-on / signed test build | +| Extension manifest loaded | `dist/firefox/manifest.json` | +| Pointa server state | Running on `http://127.0.0.1:4242` / MCP-managed / stopped for offline row | +| Backend log capture state | Off / enabled in bug UI / app launched with `pointa-server dev ` | +| Demo page server state | Running on port `8080` / other: | +| Demo URL | `http://localhost:8080/testing/demo-app/index.html` / other: | +| Local data state | Clean / demo fixtures loaded / existing data retained | + +## Matrix + +| ID | Scenario | Setup | Steps | Expected evidence | Pass/fail notes | +| --- | --- | --- | --- | --- | --- | +| FQ-01 | Firefox package and injection baseline | Server running; demo URL open in Firefox with the generated add-on loaded. | Click the Pointa toolbar icon. Open and close the toolbar/sidebar. Refresh the page and repeat once. | Toolbar/sidebar inject on the supported local page without duplicate UI. Background and page consoles show no uncaught injection or permission errors. | | +| FQ-02 | Annotation CRUD | Server running; demo URL loaded; use clean data or note fixture state. | Create an annotation on a visible element. Reload the page. Edit the comment or status. Delete/archive the annotation from the UI. | Created annotation persists after reload, remains linked to the target element, edit/status changes are reflected in the UI/server data, and deleted/archived annotation no longer appears as active. | | +| FQ-03 | Element relink resilience | Server running; create an annotation on an element with stable attributes or text. | Reload the page. If the demo supports DOM changes, make a minor DOM/order change and reload. Reopen Pointa. | Annotation resolves by stable element signals when available and falls back without crashing. If relink fails, the UI shows a recoverable missing-target state instead of an uncaught error. | | +| FQ-04 | Element-linked screenshots | Server running; demo URL loaded; target element visible in viewport. | Create or edit an annotation with screenshot capture enabled. Open the saved annotation details and image preview. | Firefox captures a visible-tab screenshot and crops the visible target element when possible. The image is attached to the annotation; capture errors are structured and do not prevent non-image annotation evidence from saving. | | +| FQ-05 | Image storage and MCP payloads | Server running; annotation from FQ-04 has at least one image; MCP client configured for Pointa. | Read current page annotations through MCP. Call the image retrieval tool for the annotation image. Compare returned metadata to the UI image. | MCP annotation data reports `has_images: true`, `image_count` greater than zero, and `image_paths` under `images/{annotationId}/...`. Image retrieval returns base64 data URLs and does not expose machine-specific absolute paths. | | +| FQ-06 | Console logs in issue recording | Server running; start a bug/issue recording on the demo URL. | After recording starts, run `console.log("pointa-qa-log")`, `console.warn("pointa-qa-warn")`, and `console.error("pointa-qa-error")` from the page console. Stop and save the report. | Timeline includes post-start console events with timestamp, relative time, level, message, and page source context. Treat this as `Approximate`; browser-level CDP log parity is not expected. | | +| FQ-07 | Runtime page errors | Server running; bug/issue recording active. | From the page console, run `setTimeout(() => { throw new Error("pointa-qa-runtime-error"); }, 0)`. Stop and save the report. | Timeline contains a page/runtime error entry, either as `page-error` or `console-error` with `subtype: page-error`, including message and source file/line/column when Firefox exposes them. | | +| FQ-08 | Promise rejections | Server running; bug/issue recording active. | From the page console, run `Promise.reject(new Error("pointa-qa-rejection"))`. Stop and save the report. | Timeline contains an unhandled rejection entry, either as `unhandled-rejection` or `console-error` with `subtype: unhandled-rejection`. Rejection reason is stringified without breaking the recorder. | | +| FQ-09 | Network metadata and failures | Server running; bug/issue recording active. | Run `fetch(window.location.href + "?pointaQa=network")`. Then run `fetch("http://localhost:9/pointa-qa-failure").catch(() => {})`. Stop and save the report. | Timeline records fetch/XHR request metadata, response status when available, request URL, method, request ID or timing context when available, and failed request reason. Response bodies are not expected in Firefox. | | +| FQ-10 | Backend logs | Pointa server running; start a local test app through `pointa-server dev `; backend log toggle enabled in the bug UI. | Start a bug/issue recording. Trigger the app to write a normal log and an error log. Stop and save the report. | Report timeline includes backend events from the shared backend-log bridge with source `backend` and backend log/warn/error types. `/api/backend-logs/status`, start, stop, and read flows work without browser-specific schema changes. | | +| FQ-11 | Offline Pointa server behavior | Stop the Pointa server. Keep Firefox and the demo page open. | Click the toolbar icon, open existing UI if possible, then try to create an annotation or save a report. Restart the server and retry the save. | Offline state is visible and actionable. Failed saves are not falsely reported as successful. No uncaught content/background errors occur. Retrying after the server returns succeeds or gives a clear remaining error. | | +| FQ-12 | Restricted pages | Add-on loaded; server state does not matter. | Open `about:addons` or `about:debugging` and click the Pointa toolbar icon. Repeat on a non-local public page such as `https://example.com`. | Browser-restricted schemes are not injected and do not crash. Non-local pages do not receive unexpected local-development behavior or permission escalation; any limitation message is user-facing and not debugger/CDP-specific. | | +| FQ-13 | Firefox permissions and host scope | Run `npm run firefox:build`; inspect the generated manifest or Firefox add-on permission view. | Confirm API permissions and host permissions. Load the add-on and note the install/runtime permission prompts. | Firefox package requests `activeTab`, `storage`, and `scripting`; host permissions cover localhost, `127.0.0.1`, `0.0.0.0`, `*.local`, `*.test`, `*.localhost`, and `` for persistent visible-tab screenshot capture. It does not request `debugger` or broad `tabs`. | | +| FQ-14 | Design mode | Server running; demo URL loaded; choose an element with page-derived text/classes. | Open design mode from the Pointa UI. Select the element, make a small design note/change, save or capture the design item, then reopen it. | Design mode is usable on the supported local page. Saved design context remains linked to the selected element, visible screenshots/previews are retained when supported, and page-derived labels render as text without broken/raw HTML. | | +| FQ-15 | Inspiration capture | Server running; demo URL or another supported local page loaded. | Start inspiration capture, select a visible component, save the capture, and reopen the saved inspiration. | Capture stores screenshot/visual context and CSS/element metadata available to the UI/server. Page-derived tag, class, and computed-style metadata render safely. If a non-local page is not supported, the limitation is explicit. | | +| FQ-16 | Degraded responsive capture | Firefox add-on loaded; open the bug, performance, design, or inspiration UI area that exposes responsive capture in Chrome. | Look for responsive viewport capture controls. If a control is visible, attempt to use it. | Responsive viewport capture is hidden or disabled in Firefox with user-facing unavailable-state copy. No `debugger` permission prompt, CDP error, or full-page/responsive screenshot promise appears. Mark as `Unavailable`, not a failure, when the UI communicates this clearly. | | +| FQ-17 | AMO warning touchpoints during QA | Server running; use rows FQ-02, FQ-10, FQ-14, and FQ-15. | Enter annotation comments, trigger report details, design labels, and inspiration metadata that include characters like `<`, `>`, quotes, and ampersands. | UI remains readable and stable. Known `innerHTML` lint warnings are release-readiness items, but manual QA should still fail any row that renders raw markup, loses text, or breaks the modal/panel. | | + +## Source Docs + +- `docs/FIREFOX_PORT.md` +- `docs/FIREFOX_EVIDENCE_CAPTURE.md` +- `docs/FIREFOX_RELEASE.md` +- `docs/FIREFOX_AMO_INNERHTML_AUDIT.md` +- `docs/FIREFOX_WEB_EXT_SMOKE.md` diff --git a/docs/FIREFOX_RELEASE.md b/docs/FIREFOX_RELEASE.md new file mode 100644 index 0000000..9fec4b6 --- /dev/null +++ b/docs/FIREFOX_RELEASE.md @@ -0,0 +1,109 @@ +# Firefox Release Path + +This document records the current release decision for the Firefox port. It does +not publish anything by itself. + +## Distribution Decision + +Initial target: **internal/beta Firefox package first**, then decide between +listed AMO and unlisted self-distributed signing after smoke testing and AMO risk +review. + +Rationale: + +- The current Firefox package lints with zero errors, but still has known warning + classes for `chrome.debugger` references and dynamic `innerHTML` assignments. +- The Firefox evidence model intentionally differs from Chrome CDP behavior. +- A signed internal/beta artifact lets maintainers test install, localhost + permissions, screenshots, annotations, and evidence capture before public + listing copy is finalized. + +## Required Accounts and Credentials + +Release signing requires Mozilla Add-ons credentials owned by a maintainer: + +- AMO issuer / API key +- AMO secret +- stable Gecko add-on ID, currently generated as `pointa@pointa.dev` + +Do not commit signing credentials. They should be supplied through environment +variables or a CI secret store. + +## Build and Package Commands + +Generate the Firefox source package: + +```bash +npm run firefox:build +``` + +Run Firefox lint: + +```bash +npm run firefox:lint +``` + +Create an unsigned zip artifact: + +```bash +npm run firefox:package +``` + +Current unsigned output path: + +```text +dist/firefox-artifacts/pointa-1.3.6.zip +``` + +Signing should use the generated Firefox source or package through `web-ext sign` +once the maintainer credentials and distribution channel are selected. + +## Release Blockers + +- T-006: Firefox background/action injection must be smoke-tested. +- T-007: local server URL and health handling must be centralized. +- T-009: Firefox permissions and local host scope must be finalized. +- T-020: Firefox evidence parity/degraded-state matrix in + `docs/FIREFOX_EVIDENCE_CAPTURE.md` must be current. +- T-023: Firefox `web-ext run` demo smoke test must pass. +- T-024: Chrome regression check must pass after shared-code changes. +- T-025: AMO `innerHTML` warning audit must have dispositions. +- T-028: data collection and privacy declaration must match actual behavior. +- T-029: release readiness report must recommend release, beta, or defer. + +## Public Listing Notes + +If the project chooses listed AMO distribution, listing copy must describe: + +- local-first storage and `pointa-server` on `127.0.0.1:4242`; +- local development host permissions plus `` for persistent + visible-tab screenshot capture, with no Firefox `tabs` permission in the + generated package; +- screenshots and annotation data stored locally; +- console/error/network/backend evidence capture only when recording is active; +- Firefox-specific limitations for CDP-only Chrome features such as responsive + viewport emulation. +- privacy and data collection behavior from + `docs/FIREFOX_PRIVACY_DECLARATION.md`, including local-first storage, MCP AI + tool sharing, and optional Linear export behavior. + +QA and listing copy should use the Available, Approximate, and Unavailable labels +from `docs/FIREFOX_EVIDENCE_CAPTURE.md` so Firefox limitations are described +consistently across testing, release notes, and user-facing degraded states. +See `docs/FIREFOX_RELEASE_READINESS.md` for the current closeout decision, +validation evidence, remaining risks, and public-release gate. + +## AMO Permission Notes + +The generated Firefox package requests `activeTab`, `storage`, and `scripting`, +plus local-development host permissions and ``. `activeTab` is used for +toolbar-click access; `` is used for reliable persistent visible-tab +screenshot capture after navigation or toolbar auto-reopen; local host +permissions are used for annotation pages and `pointa-server` communication. The +Chrome-only `debugger` and broad `tabs` permissions are removed from the Firefox +artifact. + +The generated manifest declares required Firefox data collection permissions for +`websiteActivity` and `websiteContent`; see +`docs/FIREFOX_PRIVACY_DECLARATION.md` for the release wording and integration +data-handling notes. diff --git a/docs/FIREFOX_RELEASE_READINESS.md b/docs/FIREFOX_RELEASE_READINESS.md new file mode 100644 index 0000000..e78ddb5 --- /dev/null +++ b/docs/FIREFOX_RELEASE_READINESS.md @@ -0,0 +1,82 @@ +# Firefox Release Readiness + +Date: 2026-05-29 + +## Decision + +Recommendation: internal beta candidate; defer public AMO release. + +The Firefox/Zen build now supports the core local annotation workflow, local +server connectivity, annotation image attachments, and the Firefox-supported +evidence model. Public release should wait for a focused Chrome regression pass +and final AMO warning cleanup. + +## Validation Evidence + +- `npm run firefox:lint` passes with 0 errors and the documented 46-warning + baseline. +- `npm run firefox:package` creates + `dist/firefox-artifacts/pointa-1.3.6.zip`. +- `delano validate` passes with 0 errors and 1 existing compatibility warning + for missing `.claude`. +- Zen regular-profile smoke created annotation + `pointa_1780090769092_zb4jkbqsh` on `http://127.0.0.1:3977/`. +- That annotation has one WebP screenshot attachment: + `1124 x 946 px`, `17,924 bytes`. +- `http://127.0.0.1:4242/health` returns server status `ok`, version `1.3.6`. +- Chrome regression was attempted with a temporary Chrome profile, but the + installed Google Chrome build logged `--load-extension is not allowed in Google + Chrome, ignoring.`, so the full Chrome extension UI/CDP pass remains deferred. + +## Firefox Scope + +Available: + +- Firefox package generation from shared extension source. +- Firefox manifest transformation with Gecko settings and data collection + declaration. +- Local server health and MCP status checks from Firefox/Zen. +- Content script injection with clone-safe generated script completions. +- Annotation creation, deletion, image upload, and MCP image payloads. +- Visible-tab screenshot capture and content-side cropping for visible areas. +- Console, page error, promise rejection, network metadata, and backend log + capture through Firefox-supported fallbacks. + +Unavailable or degraded: + +- Chrome debugger/CDP responsive viewport emulation. +- CDP full-page or beyond-viewport screenshot capture. +- CDP-level network response body capture. +- Exact Chrome CDP timeline parity. + +## Permission Notes + +The Firefox artifact requests `activeTab`, `storage`, and `scripting` API +permissions. It removes Chrome-only `debugger` and broad `tabs` permissions. + +The Firefox artifact includes local-development host permissions and +``. `` is required for reliable persistent in-page +`tabs.captureVisibleTab` screenshots after navigation or toolbar auto-reopen, +where a temporary `activeTab` grant may no longer be present. + +## Remaining Risks + +- AMO may require additional dynamic `innerHTML` cleanup before listed release. +- Chrome regression has not received a full interactive annotation/screenshot/CDP + pass after the shared-source Firefox changes because the available Google + Chrome runtime rejects unpacked extension loading from automation. +- Firefox issue-recording console/error evidence has implementation and isolated + validation coverage, but no persisted local issue report was present during + final closeout. + +## Release Gate + +Internal beta: ready. + +Public listed release: defer until: + +- Chrome regression pass is completed in a real Chrome profile. +- AMO warning cleanup is either completed or accepted with a documented reviewer + rationale. +- Manual QA matrix rows for annotation screenshot and issue evidence are filled + with final pass/fail notes. diff --git a/docs/FIREFOX_WEB_EXT_SMOKE.md b/docs/FIREFOX_WEB_EXT_SMOKE.md new file mode 100644 index 0000000..3d1667c --- /dev/null +++ b/docs/FIREFOX_WEB_EXT_SMOKE.md @@ -0,0 +1,84 @@ +# Firefox Web-Ext Smoke Evidence + +Date: 2026-05-29 + +## Environment + +- Browser binary: `C:\Program Files\Zen Browser\zen.exe` +- Browser version: `Mozilla Zen 1.20b` +- Generated package: `dist/firefox/` +- Demo URL: `http://127.0.0.1:8080/testing/demo-app/index.html` +- Pointa server: `http://127.0.0.1:4242` + +## Automated Evidence + +`web-ext run` was executed against Zen with an isolated temporary profile and the +generated Firefox package: + +```bash +npx --yes web-ext run \ + --source-dir "dist/firefox" \ + --firefox-binary "C:\Program Files\Zen Browser\zen.exe" \ + --firefox-profile "" \ + --profile-create-if-missing \ + --keep-profile-changes \ + --start-url "http://127.0.0.1:8080/testing/demo-app/index.html" \ + --no-reload \ + --no-input \ + --verbose \ + --arg=-no-remote +``` + +The run validated the manifest, launched Zen against the demo URL, connected to +the Firefox remote debugger, and installed the generated extension as a +temporary add-on: + +```text +Running web extension from E:\Development\pointa\dist\firefox +Validating manifest at E:\Development\pointa\dist\firefox\manifest.json +Executing Firefox binary: C:\Program Files\Zen Browser\zen.exe +Installed E:\Development\pointa\dist\firefox as a temporary add-on +``` + +Temporary-profile Zen processes were stopped after the run. Existing user Zen +processes were not terminated. + +## Not Automated + +The full browser interaction smoke is still manual because the available local +browser automation controls Chromium, not the user's installed Zen profile. +Zen headless/temp-profile protocol probes exited before the debugger socket was +stable enough for scripted UI interaction. Do not treat this as a product +failure: the `web-ext` installation path above succeeded. + +Manual coverage still needed for T-023: + +- create an annotation in Zen on the demo page; +- attach or verify a screenshot on the annotation; +- run at least one console/error evidence capture scenario, or record why the + browser/runtime makes it unavailable. + +## Manual Zen Profile Smoke + +Use this when testing with the regular Zen profile: + +1. Run `npm run firefox:build`. +2. Open `about:debugging#/runtime/this-firefox` in Zen. +3. Choose `Load Temporary Add-on...`. +4. Select `dist/firefox/manifest.json`. +5. Open `http://127.0.0.1:8080/testing/demo-app/index.html`. +6. Click the Pointa toolbar icon and create an annotation on a visible element. +7. Confirm the saved annotation remains linked after reload. +8. Capture or attach a screenshot and confirm the image appears in the saved + annotation details. +9. Start a bug/issue recording, run one of these in the page console, stop the + recording, and confirm the event is present in the saved report: + +```js +console.error("pointa-zen-smoke-error"); +setTimeout(() => { throw new Error("pointa-zen-smoke-runtime-error"); }, 0); +Promise.reject(new Error("pointa-zen-smoke-rejection")); +``` + +Record the result in `docs/FIREFOX_QA_MATRIX.md` row FQ-02, FQ-04, and one of +FQ-06 through FQ-08. diff --git a/docs/POI-10-server-logs-investigation.md b/docs/POI-10-server-logs-investigation.md index c3741f6..967a48a 100644 --- a/docs/POI-10-server-logs-investigation.md +++ b/docs/POI-10-server-logs-investigation.md @@ -13,7 +13,7 @@ import 'pointa-server-logger'; // Side-effect import **How it works:** 1. SDK intercepts `console.log/warn/error/info/debug` 2. Connects via WebSocket to `ws://127.0.0.1:4242/backend-logs` -3. Only sends logs when Chrome extension signals "recording active" +3. Only sends logs when the Pointa browser extension signals "recording active" 4. Logs are timestamped and included in bug report timeline **Pain points:** @@ -665,7 +665,7 @@ $ pointa dev npm run dev ### 2.4 Recording Flow (Same as Current) -1. User opens their app in Chrome with Pointa extension +1. User opens their app in a supported browser with the Pointa extension 2. User triggers bug recording in extension 3. Extension calls `POST /api/backend-logs/start` 4. Server broadcasts `{ type: 'start_recording' }` to all connected preload instances diff --git a/docs/UPDATE_SYSTEM.md b/docs/UPDATE_SYSTEM.md index e85e2ba..4592304 100644 --- a/docs/UPDATE_SYSTEM.md +++ b/docs/UPDATE_SYSTEM.md @@ -5,7 +5,7 @@ This document describes the comprehensive update notification system implemented ## Overview The update system consists of three main components: -1. **Extension Update Detection** - Detects Chrome extension updates automatically +1. **Extension Update Detection** - Detects installed extension updates automatically 2. **Server Update Checking** - Monitors GitHub releases for server package updates 3. **Version Compatibility** - Ensures extension and server versions work together @@ -13,7 +13,7 @@ The update system consists of three main components: ``` ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ Chrome Extension│ │ Local Server │ │ GitHub Releases │ +│Browser Extension│ │ Local Server │ │ GitHub Releases │ │ │ │ │ │ │ │ Update Detection│◄──►│ Version API │◄──►│ Latest Releases │ │ Badge Notification │ Compatibility │ │ Version Tags │ @@ -25,7 +25,7 @@ The update system consists of three main components: ### How It Works -The Chrome extension automatically detects when it has been updated using the `chrome.runtime.onInstalled` API. +The installed extension automatically detects when it has been updated using the `chrome.runtime.onInstalled` API. Chrome Web Store updates happen automatically; Firefox/Zen local beta builds are updated manually until the AMO release path is active. **File**: `extension/background/background.js` @@ -39,7 +39,7 @@ chrome.runtime.onInstalled.addListener((details) => { ### Update Flow -1. **Chrome Updates Extension** - Happens automatically via Chrome Web Store +1. **Browser Updates Extension** - Happens automatically for the Chrome Web Store package; Firefox/Zen local builds are reloaded manually 2. **Background Script Detects Update** - `onInstalled` event fires with reason 'update' 3. **Store Update Info** - Saves update details to Chrome storage 4. **Show Badge** - Displays "NEW" badge on extension icon @@ -187,8 +187,9 @@ async checkAPIConnectionStatus() { ### Update Flow for Users -1. **Extension Updates** (automatic via Chrome): - - Chrome updates extension silently +1. **Extension Updates**: + - Chrome updates the Web Store extension silently + - Firefox/Zen local beta builds are rebuilt and reloaded manually - User sees "NEW" badge on extension icon - Clicking extension shows update banner - User can view changelog or dismiss notification @@ -240,7 +241,8 @@ Update notifications can be configured in the extension: - Update `manifest.json` version - Add changelog entry to `getChangelogForVersion()` - Test update notification locally - - Submit to Chrome Web Store + - Submit Chrome builds to Chrome Web Store + - Package Firefox builds with `npm run firefox:package` for AMO/internal beta release 2. **Server Updates**: - Update `package.json` version @@ -304,7 +306,7 @@ Follow [Semantic Versioning](https://semver.org/): ### Extension Security -- Update notifications use Chrome Storage API (local only) +- Update notifications use extension local storage (Chrome-compatible `chrome.storage.local`) - No external network requests from extension - Version data validated before use - XSS protection in changelog display @@ -324,7 +326,7 @@ curl https://registry.npmjs.org/pointa-server/latest **Extension badge not clearing**: - Open extension popup (badge clears automatically) -- Check Chrome storage: `chrome.storage.local.get(['updateInfo'])` +- Check extension storage: `chrome.storage.local.get(['updateInfo'])` **Version compatibility errors**: - Verify extension and server versions @@ -359,4 +361,4 @@ When contributing to the update system: ## Changelog -See [CHANGELOG.md](../CHANGELOG.md) for detailed version history and [annotations-server/CHANGELOG.md](../annotations-server/CHANGELOG.md) for server changes. \ No newline at end of file +See [CHANGELOG.md](../CHANGELOG.md) for detailed version history and [annotations-server/CHANGELOG.md](../annotations-server/CHANGELOG.md) for server changes. diff --git a/extension/background/background.js b/extension/background/background.js index 1dcc510..9a2c29c 100644 --- a/extension/background/background.js +++ b/extension/background/background.js @@ -1,5 +1,39 @@ // Pointa Background Service Worker +function loadPointaBrowserCompat(callback) { + const root = typeof globalThis !== 'undefined' ? globalThis : self; + + if (root.PointaBrowser) { + callback(); + return; + } + + if (typeof importScripts === 'function') { + try { + importScripts('../common/browser-compat.js'); + } catch (error) { + console.warn('[Background] Failed to load browser compatibility helper:', error.message); + } + callback(); + return; + } + + const extensionApi = root.browser || root.chrome; + if (typeof document !== 'undefined' && extensionApi?.runtime?.getURL) { + const script = document.createElement('script'); + script.src = extensionApi.runtime.getURL('common/browser-compat.js'); + script.onload = () => callback(); + script.onerror = () => { + console.warn('[Background] Failed to load browser compatibility helper script'); + callback(); + }; + (document.head || document.documentElement).appendChild(script); + return; + } + + callback(); +} + // Note: FileStorageManager is NOT imported here because: // - File System Access API is only available in window contexts (not service workers) // - Background service worker only communicates with API server @@ -8,16 +42,21 @@ class PointaBackground { constructor() { - this.apiServerUrl = 'http://127.0.0.1:4242'; // Port 4242 - the answer to life, the universe, and everything + this.browserCompat = globalThis.PointaBrowser; + if (!this.browserCompat) { + throw new Error('Pointa browser compatibility helper failed to load.'); + } + this.apiServerUrl = this.browserCompat.getLocalServerBaseUrl(); this.apiConnected = false; + this.capabilities = this.getCapabilities(); // Note: FileStorageManager is NOT used in service worker context // File System Access API is only available in window contexts // All file operations go through the API server instead // this.fileStorage = new FileStorageManager(); this.viewportOverrides = new Map(); // Track viewport overrides for responsive capture - // 🔒 Injection lock: Prevent concurrent content script injections for the same tab - this.injectionLocks = new Set(); // Set of tabIds currently being injected + // Injection lock: reuse one in-flight injection per tab. + this.injectionLocks = new Map(); // tabId -> Promise // 🎯 CDP Recording: Track active CDP recordings per tab // This captures network/console via Chrome DevTools Protocol (CDP) @@ -30,6 +69,33 @@ class PointaBackground { this.init(); } + getCapabilities() { + if (globalThis.PointaBrowser?.getCapabilities) { + return globalThis.PointaBrowser.getCapabilities(); + } + + return { + namespace: typeof browser !== 'undefined' ? 'browser' : typeof chrome !== 'undefined' ? 'chrome' : 'none', + hasBrowserNamespace: typeof browser !== 'undefined', + hasChromeNamespace: typeof chrome !== 'undefined', + isFirefox: typeof navigator !== 'undefined' && /Firefox\//.test(navigator.userAgent), + isChromium: typeof navigator !== 'undefined' && /(?:Chrome|Chromium|Edg)\//.test(navigator.userAgent) && !/Firefox\//.test(navigator.userAgent), + action: typeof chrome !== 'undefined' && Boolean(chrome.action), + runtime: typeof chrome !== 'undefined' && Boolean(chrome.runtime), + storage: typeof chrome !== 'undefined' && Boolean(chrome.storage?.local), + tabs: typeof chrome !== 'undefined' && Boolean(chrome.tabs), + captureVisibleTab: typeof chrome !== 'undefined' && Boolean(chrome.tabs?.captureVisibleTab), + scripting: typeof chrome !== 'undefined' && Boolean(chrome.scripting?.executeScript && chrome.scripting?.insertCSS), + debugger: typeof chrome !== 'undefined' && + !(typeof navigator !== 'undefined' && /Firefox\//.test(navigator.userAgent)) && + Boolean(chrome.debugger?.attach && chrome.debugger?.detach && chrome.debugger?.sendCommand && chrome.debugger?.onEvent && chrome.debugger?.onDetach) + }; + } + + hasCapability(name) { + return Boolean(this.capabilities && this.capabilities[name]); + } + init() { // Set up event listeners @@ -38,7 +104,9 @@ class PointaBackground { this.setupTabListener(); this.setupStorageListener(); this.setupActionClickListener(); - this.setupCDPEventListener(); // CDP network/console capture + if (this.hasCapability('debugger')) { + this.setupCDPEventListener(); // CDP network/console capture + } // Sync bug reports from API server on startup this.syncBugReportsFromAPI().catch((err) => { @@ -124,6 +192,11 @@ class PointaBackground { chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { switch (request.action) { + case 'getCapabilities': + case 'getExtensionCapabilities': + sendResponse({ success: true, capabilities: this.getCapabilities() }); + break; + case 'getAnnotations': this.getAnnotations(request.url, request.limit). then((annotations) => sendResponse({ success: true, annotations })). @@ -181,9 +254,14 @@ class PointaBackground { break; case 'captureScreenshot': - this.captureScreenshot(sender.tab.id). + this.captureScreenshot(sender.tab?.id, sender.tab?.windowId). then((dataUrl) => sendResponse({ success: true, dataUrl })). - catch((error) => sendResponse({ success: false, error: error.message })); + catch((error) => sendResponse({ + success: false, + error: error.message, + code: error.code || 'SCREENSHOT_CAPTURE_FAILED', + details: error.details || null + })); break; case 'getBugReports': @@ -267,7 +345,7 @@ class PointaBackground { break; case 'ensureContentScriptsInjected': - this.ensureContentScriptsInjected(request.tabId). + this.ensureContentScriptsInjected(request.tabId, request.url || null). then(() => sendResponse({ success: true })). catch((error) => sendResponse({ success: false, error: error.message })); break; @@ -286,6 +364,30 @@ class PointaBackground { catch((error) => sendResponse({ success: false, error: error.message })); break; + case 'startPageNetworkInstrumentation': + this.startPageNetworkInstrumentation(sender.tab?.id, request.config). + then(() => sendResponse({ success: true })). + catch((error) => sendResponse({ success: false, error: error.message })); + break; + + case 'stopPageNetworkInstrumentation': + this.stopPageNetworkInstrumentation(sender.tab?.id, request.config). + then(() => sendResponse({ success: true })). + catch((error) => sendResponse({ success: false, error: error.message })); + break; + + case 'startPageConsoleInstrumentation': + this.startPageConsoleInstrumentation(sender.tab?.id, request.config). + then(() => sendResponse({ success: true })). + catch((error) => sendResponse({ success: false, error: error.message })); + break; + + case 'stopPageConsoleInstrumentation': + this.stopPageConsoleInstrumentation(sender.tab?.id, request.config). + then(() => sendResponse({ success: true })). + catch((error) => sendResponse({ success: false, error: error.message })); + break; + // Backend Logs: Get connection status (optionally for a specific port) case 'getBackendLogStatus': console.log('[Background] getBackendLogStatus called with port:', request.port); @@ -404,7 +506,7 @@ class PointaBackground { // Re-inject content scripts when navigating to a localhost page // This ensures the extension works after full page reloads (e.g., from dropdown navigation) try { - await this.ensureContentScriptsInjected(tabId); + await this.ensureContentScriptsInjected(tabId, tab.url); } catch (error) { console.error('Error re-injecting content scripts on navigation:', error); } @@ -422,53 +524,157 @@ class PointaBackground { // No longer needed - we don't use local storage for annotations // Keeping method for potential future use with other settings - }setupActionClickListener() { - // Handle extension icon click to toggle sidebar or show onboarding + } + + setupActionClickListener() { + // Handle extension icon click to toggle sidebar or show onboarding. chrome.action.onClicked.addListener(async (tab) => { + const unsupportedReason = this.getUnsupportedInjectionReason(tab?.url); + if (unsupportedReason) { + console.debug('[Background] Pointa is unavailable on this page:', unsupportedReason); + return; + } + try { - // Skip chrome:// and chrome-extension:// pages - if (!tab.url || tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://')) { + const injection = await this.ensureContentScriptsInjected(tab.id, tab.url); + if (!injection.injected && !injection.alreadyInjected) { return; } - // Inject content scripts if not already injected - await this.ensureContentScriptsInjected(tab.id); + const result = await chrome.storage.local.get(['onboardingCompleted']); + const action = !result.onboardingCompleted && this.isLocalhostUrl(tab.url) + ? 'showOnboarding' + : 'toggleSidebar'; + + await this.sendTabMessage(tab.id, { action }); + } catch (error) { + console.warn('[Background] Extension click could not be handled:', error.message); + } + }); + } - // Small delay to ensure scripts are loaded - await new Promise((resolve) => setTimeout(resolve, 100)); + getUnsupportedInjectionReason(url) { + if (!url) { + return 'No active tab URL is available'; + } - // Check if we should show onboarding (first time use) - const result = await chrome.storage.local.get(['onboardingCompleted']); + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch { + return 'The active tab URL is invalid'; + } - // Check if on localhost/local development URL - const isLocalhost = this.isLocalhostUrl(tab.url); + const blockedProtocols = new Set([ + 'about:', + 'chrome:', + 'chrome-extension:', + 'devtools:', + 'edge:', + 'moz-extension:', + 'view-source:' + ]); + + if (blockedProtocols.has(parsedUrl.protocol)) { + return `${parsedUrl.protocol} pages do not allow extension content scripts`; + } - if (!result.onboardingCompleted && isLocalhost) { - // First time on localhost - show onboarding - await chrome.tabs.sendMessage(tab.id, { action: 'showOnboarding' }); - } else { - // Normal use - toggle sidebar - await chrome.tabs.sendMessage(tab.id, { action: 'toggleSidebar' }); - } - } catch (error) { - console.error('Error handling extension click:', error); - // If content script not loaded, try to inject it - try { - await this.ensureContentScriptsInjected(tab.id); - await new Promise((resolve) => setTimeout(resolve, 200)); - await chrome.tabs.sendMessage(tab.id, { action: 'toggleSidebar' }); - } catch (injectError) { - console.error('Error injecting content scripts:', injectError); + if (!['http:', 'https:', 'file:'].includes(parsedUrl.protocol)) { + return `${parsedUrl.protocol} pages are not supported`; + } + + return null; + } + + async sendTabMessage(tabId, message) { + try { + return await chrome.tabs.sendMessage(tabId, message); + } catch (error) { + if (/receiving end does not exist|Could not establish connection/i.test(error.message || '')) { + return null; + } + throw error; + } + } + + getContentScriptFiles() { + return [ + 'common/browser-compat.js', + 'content/modules/utils.js', + 'content/modules/theme-manager.js', + 'content/modules/selector-generator.js', + 'content/modules/element-finder.js', + 'content/modules/context-analyzer.js', + 'content/modules/badge-manager.js', + 'content/modules/image-uploader.js', + 'content/modules/annotation-mode.js', + 'content/modules/annotation-factory.js', + 'content/modules/design-mode.js', + 'content/modules/design-editor-ui.js', + 'content/modules/onboarding-overlay.js', + 'content/modules/bug-recorder.js', + 'content/modules/bug-report-ui.js', + 'content/modules/bug-replay-engine.js', + 'content/modules/performance-recorder.js', + 'content/modules/performance-report-ui.js', + 'content/modules/sidebar-ui.js', + 'content/modules/report-details.js', + 'content/modules/toolbar-drag.js', + 'content/modules/toolbar-panels.js', + 'content/modules/floating-toolbar.js', + 'content/content.js' + ]; + } + + async getInjectionState(tabId) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: () => ({ + pointaReady: typeof window.pointa !== 'undefined', + cssInserted: Boolean(window.__POINTA_CSS_INSERTED__), + injectedFiles: Array.isArray(window.__POINTA_INJECTED_FILES__) + ? window.__POINTA_INJECTED_FILES__ + : [] + }) + }); + + return results?.[0]?.result || { + pointaReady: false, + cssInserted: false, + injectedFiles: [] + }; + } + + async markCssInjected(tabId) { + await chrome.scripting.executeScript({ + target: { tabId }, + func: () => { + window.__POINTA_CSS_INSERTED__ = true; + } + }); + } + + async markScriptInjected(tabId, file) { + await chrome.scripting.executeScript({ + target: { tabId }, + args: [file], + func: (scriptFile) => { + window.__POINTA_INJECTED_FILES__ = Array.isArray(window.__POINTA_INJECTED_FILES__) + ? window.__POINTA_INJECTED_FILES__ + : []; + + if (!window.__POINTA_INJECTED_FILES__.includes(scriptFile)) { + window.__POINTA_INJECTED_FILES__.push(scriptFile); } } }); } /** - * Ensure content scripts are injected into the tab - * Only injects if not already present (checks for window.pointa) + * Legacy injection implementation retained for compatibility while the + * Firefox-safe implementation below owns current call sites. */ - async ensureContentScriptsInjected(tabId) { + async ensureContentScriptsInjectedLegacy(tabId) { // 🔒 Prevent concurrent injections for the same tab (race condition fix) if (this.injectionLocks.has(tabId)) { console.log(`[Background] Injection already in progress for tab ${tabId}, skipping`); @@ -488,7 +694,7 @@ class PointaBackground { } // Acquire lock before injecting - this.injectionLocks.add(tabId); + this.injectionLocks.set(tabId, Promise.resolve()); // Inject CSS first await chrome.scripting.insertCSS({ @@ -498,6 +704,7 @@ class PointaBackground { // Inject all JavaScript modules in order const scriptFiles = [ + 'common/browser-compat.js', 'content/modules/utils.js', 'content/modules/theme-manager.js', 'content/modules/selector-generator.js', @@ -551,6 +758,74 @@ class PointaBackground { // 🗑️ REMOVED: onAnnotationsChanged() - No longer needed without local storage sync // 🗑️ REMOVED: syncAnnotationsToAPI() - No longer needed without local storage sync + /** + * Ensure content scripts are injected into the tab. + * Per-file markers make retries safe after partial injection failures. + */ + async ensureContentScriptsInjected(tabId, url = null) { + if (!url) { + try { + const tab = await chrome.tabs.get(tabId); + url = tab?.url || null; + } catch { + url = null; + } + } + + const unsupportedReason = this.getUnsupportedInjectionReason(url); + if (unsupportedReason) { + return { injected: false, alreadyInjected: false, reason: unsupportedReason }; + } + + if (!this.hasCapability('scripting')) { + throw new Error('The scripting API is unavailable in this browser.'); + } + + if (this.injectionLocks.has(tabId)) { + return this.injectionLocks.get(tabId); + } + + const injectionPromise = this.injectContentScripts(tabId); + this.injectionLocks.set(tabId, injectionPromise); + + try { + return await injectionPromise; + } finally { + this.injectionLocks.delete(tabId); + } + } + + async injectContentScripts(tabId) { + const initialState = await this.getInjectionState(tabId); + if (initialState.pointaReady) { + return { injected: false, alreadyInjected: true }; + } + + if (!initialState.cssInserted) { + await chrome.scripting.insertCSS({ + target: { tabId }, + files: ['content/content.css'] + }); + await this.markCssInjected(tabId); + } + + const injectedFiles = new Set(initialState.injectedFiles || []); + for (const file of this.getContentScriptFiles()) { + if (injectedFiles.has(file)) { + continue; + } + + await chrome.scripting.executeScript({ + target: { tabId }, + files: [file] + }); + await this.markScriptInjected(tabId, file); + injectedFiles.add(file); + } + + return { injected: true, alreadyInjected: false }; + } + // Helper to get normalized origin + pathname (ignoring query params, hash, trailing slash) getUrlPath(url) { try { @@ -890,9 +1165,16 @@ class PointaBackground { async updateBadgeForUrl(url) { try { - const tabs = await chrome.tabs.query({ url: url }); - for (const tab of tabs) { - await this.updateBadgeFromAPI(tab.id, url); + const targetPath = this.getUrlPath(url); + const tabs = await chrome.tabs.query({}); + const matchingTabs = tabs.filter((tab) => ( + tab.url && + this.isLocalhostUrl(tab.url) && + this.getUrlPath(tab.url) === targetPath + )); + + for (const tab of matchingTabs) { + await this.updateBadgeFromAPI(tab.id, tab.url); } } catch (error) { console.error('Error updating badge for URL:', url, error); @@ -914,13 +1196,12 @@ class PointaBackground { async checkAPIConnectionStatus() { try { - const response = await fetch(`${this.apiServerUrl}/health`, { - method: 'GET', - signal: AbortSignal.timeout(5000) // 5 second timeout + const status = await this.browserCompat.checkLocalServerHealth({ + timeoutMs: 5000 }); - if (response.ok) { - const data = await response.json(); + if (status.connected) { + const data = status.data || {}; this.apiConnected = true; // Check version compatibility @@ -945,31 +1226,32 @@ class PointaBackground { } return { + ...status, connected: true, server_url: this.apiServerUrl, server_version: data.version, server_status: data.status, version_compatible: versionCompatible, compatibility_message: compatibilityMessage, - last_check: new Date().toISOString() + last_check: status.last_check }; } else { this.apiConnected = false; return { + ...status, connected: false, server_url: this.apiServerUrl, - error: `Server returned ${response.status}`, - last_check: new Date().toISOString() + error: status.error, + last_check: status.last_check }; } } catch (error) { this.apiConnected = false; - return { + return this.browserCompat.normalizeLocalServerStatus({ connected: false, server_url: this.apiServerUrl, - error: error.message, - last_check: new Date().toISOString() - }; + error: error.message + }); } } @@ -1079,7 +1361,7 @@ class PointaBackground { * * SECURITY: This is safe because: * 1. Only checks server health (read-only, no data sent) - * 2. Only connects to 127.0.0.1:4242 (local loopback, not accessible externally) + * 2. Only connects to the canonical local loopback server URL * 3. Only used during onboarding setup flow * 4. Background scripts have proper permissions for local network access * @@ -1087,37 +1369,44 @@ class PointaBackground { * to navigate to localhost just to complete setup, which is poor UX. */ try { - const response = await fetch(`${this.apiServerUrl}/health`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - signal: AbortSignal.timeout(5000) // 5 second timeout + const status = await this.browserCompat.checkLocalServerHealth({ + timeoutMs: 5000 }); - if (response.ok) { - const data = await response.json(); + if (status.connected) { return { serverOnline: true, - serverVersion: data.version, - serverStatus: data.status + serverUrl: this.apiServerUrl, + serverVersion: status.server_version, + serverStatus: status.server_status }; } else { return { serverOnline: false, - error: `Server returned status ${response.status}` + serverUrl: this.apiServerUrl, + error: status.error }; } } catch (error) { return { serverOnline: false, + serverUrl: this.apiServerUrl, error: error.message || 'Failed to connect to server' }; } } - async captureScreenshot(tabId) { + async captureScreenshot(tabId, windowId = null) { try { + if (!tabId) { + throw this.createScreenshotError( + 'NO_ACTIVE_TAB', + 'No active tab is available for screenshot capture. Open the target page and activate Pointa from that tab.' + ); + } + // Check if this tab has viewport override active (Feature 5: Responsive Capture) - if (this.viewportOverrides.has(tabId)) { + if (this.viewportOverrides.has(tabId) && this.hasCapability('debugger')) { // Capture full viewport - content script handles element cropping @@ -1138,16 +1427,123 @@ class PointaBackground { return 'data:image/png;base64,' + result.data; } - // Normal capture for non-overridden viewports - const dataUrl = await chrome.tabs.captureVisibleTab(null, { - format: 'png', - quality: 90 - }); - return dataUrl; + // Firefox and normal Chrome captures use the visible-tab image; content scripts crop element selections. + return await this.captureVisibleTab(windowId); } catch (error) { - console.error('Error capturing screenshot:', error); - throw error; + const screenshotError = this.normalizeScreenshotError(error); + console.error('Error capturing screenshot:', screenshotError); + throw screenshotError; + } + } + + async captureVisibleTab(windowId) { + if (!this.hasCapability('captureVisibleTab')) { + throw this.createScreenshotError( + 'CAPTURE_VISIBLE_TAB_UNAVAILABLE', + 'Visible-tab screenshot capture is unavailable in this browser build.' + ); } + + const captureOptions = { + format: 'png', + quality: 90 + }; + + const tabsApi = this.browserCompat?.getApi + ? this.browserCompat.getApi('tabs') + : chrome.tabs; + + if (!tabsApi || typeof tabsApi.captureVisibleTab !== 'function') { + throw this.createScreenshotError( + 'CAPTURE_VISIBLE_TAB_UNAVAILABLE', + 'Visible-tab screenshot capture is unavailable in this browser build.' + ); + } + + const hasWindowId = typeof windowId === 'number'; + + if (this.capabilities?.hasBrowserNamespace && this.browserCompat?.browser?.tabs?.captureVisibleTab) { + return hasWindowId + ? this.browserCompat.browser.tabs.captureVisibleTab(windowId, captureOptions) + : this.browserCompat.browser.tabs.captureVisibleTab(captureOptions); + } + + return new Promise((resolve, reject) => { + try { + const handleCaptured = (dataUrl) => { + const runtimeError = chrome.runtime?.lastError; + if (runtimeError) { + reject(runtimeError); + return; + } + if (!dataUrl) { + reject(new Error('Browser returned an empty screenshot.')); + return; + } + resolve(dataUrl); + }; + + const maybePromise = hasWindowId + ? tabsApi.captureVisibleTab(windowId, captureOptions, handleCaptured) + : tabsApi.captureVisibleTab(captureOptions, handleCaptured); + + if (maybePromise && typeof maybePromise.then === 'function') { + maybePromise.then(resolve, reject); + } + } catch (error) { + reject(error); + } + }); + } + + createScreenshotError(code, message, details = null) { + const error = new Error(message); + error.code = code; + error.details = details; + return error; + } + + normalizeScreenshotError(error) { + if (error?.code) { + return error; + } + + const originalMessage = error?.message || String(error || 'Unknown screenshot capture error'); + const capabilities = { + isFirefox: Boolean(this.capabilities?.isFirefox), + hasDebugger: this.hasCapability('debugger'), + hasCaptureVisibleTab: this.hasCapability('captureVisibleTab') + }; + + if (!capabilities.hasCaptureVisibleTab) { + return this.createScreenshotError( + 'CAPTURE_VISIBLE_TAB_UNAVAILABLE', + 'Visible-tab screenshot capture is unavailable in this browser build.', + { originalMessage, capabilities } + ); + } + + if (/permission|activeTab|host permission|not allowed|access/i.test(originalMessage)) { + return this.createScreenshotError( + 'SCREENSHOT_PERMISSION_DENIED', + 'Screenshot capture was blocked by browser permissions. Activate Pointa on the target tab and try again; Firefox requires activeTab access for visible-tab screenshots.', + { originalMessage, capabilities } + ); + } + + if (/cannot access|restricted|special page|privileged|internal|about:|chrome:|moz-extension:/i.test(originalMessage)) { + return this.createScreenshotError( + 'SCREENSHOT_RESTRICTED_PAGE', + 'Screenshot capture is not available on browser-internal or restricted pages. Try again from a normal http, https, localhost, or file page.', + { originalMessage, capabilities } + ); + } + + return this.createScreenshotError( + 'SCREENSHOT_CAPTURE_FAILED', + `Screenshot capture failed: ${originalMessage}`, + { originalMessage, capabilities } + ); } async getBugReports(status = 'active', url = null) { @@ -1712,6 +2108,10 @@ class PointaBackground { */ async setViewport(tabId, width, height) { try { + if (!this.hasCapability('debugger')) { + throw new Error('Responsive viewport capture is unavailable in this browser. Use the visible screenshot capture instead.'); + } + // Only attach debugger if not already attached const isAttached = this.viewportOverrides.has(tabId); @@ -1751,6 +2151,11 @@ class PointaBackground { */ async resetViewport(tabId) { try { + if (!this.hasCapability('debugger')) { + this.viewportOverrides.delete(tabId); + return; + } + // Clear device metrics override await chrome.debugger.sendCommand( { tabId }, @@ -1865,6 +2270,89 @@ class PointaBackground { } } + async startPageNetworkInstrumentation(tabId, config) { + if (!tabId) { + throw new Error('No active tab is available for network instrumentation.'); + } + if (!config?.id || !config.eventName || !config.stopEventName) { + throw new Error('Network instrumentation config is incomplete.'); + } + if (!this.hasCapability('scripting')) { + throw new Error('Page network instrumentation requires the scripting API.'); + } + + await this.setPageRecorderConfig(tabId, 'network', config); + + await chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + injectImmediately: true, + files: ['content/page-network-recorder.js'] + }); + } + + async stopPageNetworkInstrumentation(tabId, config) { + if (!tabId || !config?.id || !config.stopEventName || !this.hasCapability('scripting')) { + return; + } + + await this.dispatchPageRecorderStop(tabId, config.stopEventName); + } + + async startPageConsoleInstrumentation(tabId, config) { + if (!tabId) { + throw new Error('No active tab is available for console instrumentation.'); + } + if (!config?.id || !config.eventName || !config.stopEventName) { + throw new Error('Console instrumentation config is incomplete.'); + } + if (!this.hasCapability('scripting')) { + throw new Error('Page console instrumentation requires the scripting API.'); + } + + await this.setPageRecorderConfig(tabId, 'console', config); + + await chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + injectImmediately: true, + files: ['content/page-console-recorder.js'] + }); + } + + async stopPageConsoleInstrumentation(tabId, config) { + if (!tabId || !config?.id || !config.stopEventName || !this.hasCapability('scripting')) { + return; + } + + await this.dispatchPageRecorderStop(tabId, config.stopEventName); + } + + async setPageRecorderConfig(tabId, recorderType, config) { + await chrome.scripting.executeScript({ + target: { tabId }, + injectImmediately: true, + args: [recorderType, JSON.stringify(config)], + func: (type, serializedConfig) => { + const attrName = `data-pointa-${type}-recorder-config`; + document.documentElement.setAttribute(attrName, serializedConfig); + } + }); + } + + async dispatchPageRecorderStop(tabId, stopEventName) { + await chrome.scripting.executeScript({ + target: { tabId }, + injectImmediately: true, + args: [stopEventName], + func: (eventName) => { + const event = new Event(eventName); + window.dispatchEvent(event); + document.dispatchEvent(new Event(eventName)); + } + }); + } + // ============================================================ // CDP Recording: Chrome DevTools Protocol Network/Console Capture // ============================================================ @@ -1874,6 +2362,10 @@ class PointaBackground { * This listener handles ALL CDP events and routes them to active recordings */ setupCDPEventListener() { + if (!this.hasCapability('debugger')) { + return; + } + chrome.debugger.onEvent.addListener((source, method, params) => { const tabId = source.tabId; const recording = this.cdpRecordings.get(tabId); @@ -2078,6 +2570,10 @@ class PointaBackground { */ async startCDPRecording(tabId) { try { + if (!this.hasCapability('debugger')) { + throw new Error('Browser-level network and console capture is unavailable in this browser. Page-level recording remains available.'); + } + // Check if already recording if (this.cdpRecordings.has(tabId)) { console.warn(`[CDP] Already recording for tab ${tabId}`); @@ -2121,6 +2617,10 @@ class PointaBackground { */ async stopCDPRecording(tabId) { try { + if (!this.hasCapability('debugger')) { + return { network: [], console: [] }; + } + const recording = this.cdpRecordings.get(tabId); if (!recording) { @@ -2425,4 +2925,6 @@ class PointaBackground { } // Initialize the background service worker -new PointaBackground(); +loadPointaBrowserCompat(() => { + new PointaBackground(); +}); diff --git a/extension/common/browser-compat.js b/extension/common/browser-compat.js new file mode 100644 index 0000000..7383f48 --- /dev/null +++ b/extension/common/browser-compat.js @@ -0,0 +1,293 @@ +// Pointa cross-browser runtime and capability helpers. +(function () { + var root = typeof globalThis !== 'undefined' ? globalThis : self; + + if (root.PointaBrowser && + typeof root.PointaBrowser.getLocalServerBaseUrl === 'function' && + typeof root.PointaBrowser.checkLocalServerHealth === 'function') { + return; + } + + var chromeApi = root.chrome || null; + var browserApi = root.browser || null; + var api = browserApi || chromeApi || {}; + var namespace = browserApi ? 'browser' : chromeApi ? 'chrome' : 'none'; + var userAgent = root.navigator && root.navigator.userAgent ? root.navigator.userAgent : ''; + var isFirefox = /Firefox\//.test(userAgent); + var isChromium = /(?:Chrome|Chromium|Edg)\//.test(userAgent) && !isFirefox; + + if (isFirefox && browserApi && !chromeApi) { + root.chrome = browserApi; + chromeApi = root.chrome; + api = browserApi; + namespace = 'browser'; + } + + var LOCAL_SERVER_URL = 'http://127.0.0.1:4242'; + var LOCAL_SERVER_HEALTH_PATH = '/health'; + var LOCAL_SERVER_OFFLINE_ERROR = 'Pointa server is offline'; + var LOCAL_SERVER_HOST_ALIASES = Object.freeze(['127.0.0.1', 'localhost']); + + function normalizeLocalServerPath(path) { + if (!path) { + return ''; + } + + var normalizedPath = String(path); + if (normalizedPath.charAt(0) === '?' || normalizedPath.charAt(0) === '#') { + return normalizedPath; + } + + return normalizedPath.charAt(0) === '/' ? normalizedPath : '/' + normalizedPath; + } + + function getDefaultPort(protocol) { + return protocol === 'https:' ? '443' : '80'; + } + + function getLocalServerBaseUrl() { + return LOCAL_SERVER_URL; + } + + function getLocalServerUrl(path) { + return LOCAL_SERVER_URL + normalizeLocalServerPath(path); + } + + function getLocalServerHealthUrl() { + return getLocalServerUrl(LOCAL_SERVER_HEALTH_PATH); + } + + function isLocalServerUrl(url, pathPrefix) { + try { + var parsedUrl = new URL(url); + var canonicalUrl = new URL(LOCAL_SERVER_URL); + var parsedPort = parsedUrl.port || getDefaultPort(parsedUrl.protocol); + var canonicalPort = canonicalUrl.port || getDefaultPort(canonicalUrl.protocol); + var pathMatches = true; + + if (pathPrefix) { + pathMatches = parsedUrl.pathname.indexOf(normalizeLocalServerPath(pathPrefix)) === 0; + } + + return parsedUrl.protocol === canonicalUrl.protocol && + parsedPort === canonicalPort && + LOCAL_SERVER_HOST_ALIASES.indexOf(parsedUrl.hostname) !== -1 && + pathMatches; + } catch (_) { + return false; + } + } + + function isLocalServerHealthUrl(url) { + return isLocalServerUrl(url, LOCAL_SERVER_HEALTH_PATH); + } + + function isLocalServerBackendUrl(url) { + return isLocalServerUrl(url, '/api/backend'); + } + + function createTimeoutSignal(timeoutMs) { + if (!timeoutMs || typeof root.AbortSignal === 'undefined') { + return {}; + } + + if (typeof root.AbortSignal.timeout === 'function') { + return { signal: root.AbortSignal.timeout(timeoutMs) }; + } + + if (typeof root.AbortController !== 'function' || typeof root.setTimeout !== 'function') { + return {}; + } + + var controller = new root.AbortController(); + var timeoutId = root.setTimeout(function () { + controller.abort(); + }, timeoutMs); + + return { + signal: controller.signal, + cancel: function () { + if (typeof root.clearTimeout === 'function') { + root.clearTimeout(timeoutId); + } + } + }; + } + + function normalizeLocalServerStatus(status) { + var input = status || {}; + var connected = Boolean(input.connected || input.serverOnline); + var normalized = { + connected: connected, + serverOnline: connected, + server_url: input.server_url || LOCAL_SERVER_URL, + last_check: input.last_check || new Date().toISOString() + }; + + if (typeof input.http_status !== 'undefined') { + normalized.http_status = input.http_status; + } + if (typeof input.server_version !== 'undefined' || typeof input.serverVersion !== 'undefined') { + normalized.server_version = input.server_version || input.serverVersion; + } + if (typeof input.server_status !== 'undefined' || typeof input.serverStatus !== 'undefined') { + normalized.server_status = input.server_status || input.serverStatus; + } + if (typeof input.version_compatible !== 'undefined') { + normalized.version_compatible = input.version_compatible; + } + if (typeof input.compatibility_message !== 'undefined') { + normalized.compatibility_message = input.compatibility_message; + } + if (input.data) { + normalized.data = input.data; + } + if (!connected) { + normalized.error = input.error || LOCAL_SERVER_OFFLINE_ERROR; + } + + return normalized; + } + + async function checkLocalServerHealth(options) { + var healthOptions = options || {}; + var fetchImpl = healthOptions.fetch || root.fetch; + var timeout = createTimeoutSignal(healthOptions.timeoutMs || 5000); + + if (typeof fetchImpl !== 'function') { + return normalizeLocalServerStatus({ + connected: false, + error: 'Fetch API is unavailable in this extension context' + }); + } + + try { + var requestOptions = { + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }; + + if (timeout.signal) { + requestOptions.signal = timeout.signal; + } + if (healthOptions.mode) { + requestOptions.mode = healthOptions.mode; + } + if (healthOptions.credentials) { + requestOptions.credentials = healthOptions.credentials; + } + + var response = await fetchImpl.call(root, getLocalServerHealthUrl(), requestOptions); + + if (!response.ok) { + return normalizeLocalServerStatus({ + connected: false, + error: 'Server returned ' + response.status, + http_status: response.status + }); + } + + var data = {}; + try { + data = await response.json(); + } catch (_) { + data = {}; + } + + return normalizeLocalServerStatus({ + connected: true, + data: data, + server_version: data.version, + server_status: data.status + }); + } catch (error) { + return normalizeLocalServerStatus({ + connected: false, + error: error && error.name === 'AbortError' + ? 'Timed out connecting to Pointa server' + : error && error.message ? error.message : LOCAL_SERVER_OFFLINE_ERROR + }); + } finally { + if (typeof timeout.cancel === 'function') { + timeout.cancel(); + } + } + } + + function getApiFromNamespace(namespaceApi, path) { + var parts = path.split('.'); + var current = namespaceApi; + + for (var i = 0; i < parts.length; i += 1) { + if (!current || !(parts[i] in current)) { + return undefined; + } + current = current[parts[i]]; + } + + return current; + } + + function getApi(path) { + var value = getApiFromNamespace(browserApi, path); + return typeof value === 'undefined' ? getApiFromNamespace(chromeApi, path) : value; + } + + function hasApi(path) { + return typeof getApi(path) !== 'undefined'; + } + + var capabilities = Object.freeze({ + namespace: namespace, + hasBrowserNamespace: Boolean(browserApi), + hasChromeNamespace: Boolean(chromeApi), + isFirefox: isFirefox, + isChromium: isChromium, + action: hasApi('action'), + runtime: hasApi('runtime'), + storage: hasApi('storage.local'), + tabs: hasApi('tabs'), + captureVisibleTab: hasApi('tabs.captureVisibleTab'), + scripting: hasApi('scripting.executeScript') && hasApi('scripting.insertCSS'), + debugger: !isFirefox && + hasApi('debugger.attach') && + hasApi('debugger.detach') && + hasApi('debugger.sendCommand') && + hasApi('debugger.onEvent') && + hasApi('debugger.onDetach') + }); + + function getCapabilities() { + return capabilities; + } + + function hasCapability(name) { + return Boolean(capabilities[name]); + } + + root.PointaBrowser = Object.freeze({ + api: api, + browser: browserApi, + chrome: chromeApi, + namespace: namespace, + capabilities: capabilities, + getApi: getApi, + hasApi: hasApi, + getCapabilities: getCapabilities, + hasCapability: hasCapability, + localServer: Object.freeze({ + url: LOCAL_SERVER_URL, + healthPath: LOCAL_SERVER_HEALTH_PATH, + healthUrl: getLocalServerHealthUrl(), + offlineError: LOCAL_SERVER_OFFLINE_ERROR + }), + getLocalServerBaseUrl: getLocalServerBaseUrl, + getLocalServerUrl: getLocalServerUrl, + getLocalServerHealthUrl: getLocalServerHealthUrl, + checkLocalServerHealth: checkLocalServerHealth, + normalizeLocalServerStatus: normalizeLocalServerStatus, + isLocalServerUrl: isLocalServerUrl, + isLocalServerHealthUrl: isLocalServerHealthUrl, + isLocalServerBackendUrl: isLocalServerBackendUrl + }); +})(); diff --git a/extension/content/content.js b/extension/content/content.js index d02f016..d401c0e 100644 --- a/extension/content/content.js +++ b/extension/content/content.js @@ -585,6 +585,12 @@ class Pointa { // Get element position const rect = element.getBoundingClientRect(); + const parent = element.parentElement; + const siblingIndex = parent ? Array.from(parent.children).indexOf(element) : -1; + const sameTagSiblings = parent + ? Array.from(parent.children).filter((sibling) => sibling.tagName === element.tagName) + : []; + const sameTagIndex = sameTagSiblings.indexOf(element); const position = { x: rect.left + window.scrollX, y: rect.top + window.scrollY, @@ -603,15 +609,20 @@ class Pointa { // Get parent chain context for better element disambiguation const parentChain = PointaContextAnalyzer.getParentChainContext(element); + const stableAttributes = PointaSelectorGenerator.getStableAttributes(element); return { selector, tag: element.tagName.toLowerCase(), + id: stableAttributes.id || null, + stable_attributes: stableAttributes, // CRITICAL: Filter out temporary pointa- classes to ensure clean selectors classes: Array.from(element.classList).filter((cls) => !cls.startsWith('pointa-')), text: element.textContent.substring(0, 100).trim(), styles: relevantStyles, position, + sibling_index: siblingIndex, + same_tag_index: sameTagIndex, viewport, source_mapping: sourceMapping, parent_chain: parentChain @@ -632,6 +643,8 @@ class Pointa { const modal = document.createElement('div'); modal.className = 'pointa-comment-modal'; modal.setAttribute('data-pointa-theme', PointaThemeManager.getEffective()); + const selectorText = PointaUtils.escapeHtml(context.selector); + const annotationComment = PointaUtils.escapeHtml(annotation.comment || ''); modal.innerHTML = `
@@ -665,7 +678,7 @@ class Pointa {
- ${context.selector} + ${selectorText}
@@ -687,7 +700,7 @@ class Pointa { class="pointa-comment-textarea" placeholder="Describe what needs to be changed or improved..." maxlength="1000" - >${annotation.comment || ''} + >${annotationComment}
${PointaUtils.isMac() ? '⌘↩' : 'Ctrl+Enter'} to save
@@ -735,6 +748,7 @@ class Pointa { const modal = document.createElement('div'); modal.className = 'pointa-comment-modal'; modal.setAttribute('data-pointa-theme', PointaThemeManager.getEffective()); + const selectorText = PointaUtils.escapeHtml(context.selector); modal.innerHTML = `
@@ -768,7 +782,7 @@ class Pointa {
- ${context.selector} + ${selectorText}
@@ -839,58 +853,32 @@ class Pointa { } let status; - const isLocalhost = PointaUtils.isLocalhostUrl(window.location.href); - // For non-localhost pages OR file:// protocol, use background script to avoid browser permission dialogs - // Background script can make localhost requests without triggering permission prompts - if (!isLocalhost || PointaUtils.isFileProtocol()) { - try { - const bgResponse = await chrome.runtime.sendMessage({ - action: 'checkMCPStatus' - }); + try { + const bgResponse = await chrome.runtime.sendMessage({ + action: 'checkMCPStatus' + }); - if (bgResponse && bgResponse.success && bgResponse.status) { - status = { connected: bgResponse.status.connected }; - } else { - status = { connected: false, error: 'Background check failed' }; - } - } catch (bgError) { - console.error('[Pointa] Background API check failed:', bgError); - status = { connected: false, error: 'Cannot connect to API server' }; + if (bgResponse && bgResponse.success && bgResponse.status) { + status = window.PointaBrowser.normalizeLocalServerStatus(bgResponse.status); + } else { + status = window.PointaBrowser.normalizeLocalServerStatus({ + connected: false, + error: bgResponse?.error || 'Background check failed' + }); } - } else { - // For localhost URLs, try direct fetch first + } catch (bgError) { try { - const response = await fetch('http://127.0.0.1:4242/health', { - method: 'GET', - signal: AbortSignal.timeout(2000), // 2 second timeout - mode: 'cors', // Explicitly set CORS mode - credentials: 'omit' // Don't send credentials for localhost + status = await window.PointaBrowser.checkLocalServerHealth({ + timeoutMs: 2000, + mode: 'cors', + credentials: 'omit' + }); + } catch (directError) { + status = window.PointaBrowser.normalizeLocalServerStatus({ + connected: false, + error: directError.message || bgError.message || 'Cannot connect to API server' }); - - if (response.ok) { - status = { connected: true }; - } else { - status = { connected: false, error: `Server returned ${response.status}` }; - } - } catch (error) { - // If direct fetch fails, try via background script as fallback - console.warn('Direct API check failed, trying via background script:', error); - - try { - const bgResponse = await chrome.runtime.sendMessage({ - action: 'checkMCPStatus' - }); - - if (bgResponse && bgResponse.success && bgResponse.status) { - status = { connected: bgResponse.status.connected }; - } else { - status = { connected: false, error: 'Background check failed' }; - } - } catch (bgError) { - console.error('Background API check also failed:', bgError); - status = { connected: false, error: error.message }; - } } } @@ -1414,7 +1402,7 @@ class Pointa { * @param {Object} updates - Updates to apply to the annotation */ async updateAnnotationDirectly(id, updates) { - const apiServerUrl = 'http://127.0.0.1:4242'; + const apiServerUrl = window.PointaBrowser.getLocalServerBaseUrl(); try { @@ -1452,7 +1440,7 @@ class Pointa { * @param {Object} annotation - Annotation object to save */ async saveAnnotationDirectly(annotation) { - const apiServerUrl = 'http://127.0.0.1:4242'; + const apiServerUrl = window.PointaBrowser.getLocalServerBaseUrl(); try { @@ -1490,7 +1478,7 @@ class Pointa { * @param {string} id - Annotation ID to delete */ async deleteAnnotationDirectly(id) { - const apiServerUrl = 'http://127.0.0.1:4242'; + const apiServerUrl = window.PointaBrowser.getLocalServerBaseUrl(); try { @@ -1528,7 +1516,7 @@ class Pointa { * @returns {Array} Array of annotations */ async getAnnotationsDirectly(url) { - const apiServerUrl = 'http://127.0.0.1:4242'; + const apiServerUrl = window.PointaBrowser.getLocalServerBaseUrl(); try { @@ -2491,4 +2479,4 @@ if (document.readyState === 'loading') { }); } else { window.pointa = new Pointa(); -} \ No newline at end of file +} diff --git a/extension/content/modules/annotation-factory.js b/extension/content/modules/annotation-factory.js index 664c4b4..c91bb4c 100644 --- a/extension/content/modules/annotation-factory.js +++ b/extension/content/modules/annotation-factory.js @@ -39,12 +39,13 @@ const PointaAnnotationFactory = { position: context.styles?.position }; - // Simplified parent chain - only 1 level, minimal info + // Simplified parent chain - enough context to survive nearby DOM shifts. const minimalParentChain = context.parent_chain - ? [context.parent_chain[0]].map(parent => ({ + ? context.parent_chain.slice(0, 2).map(parent => ({ tag: parent.tag, classes: parent.classes.slice(0, 2), // Just first 2 classes - id: parent.id + id: parent.id, + stable_attributes: parent.stable_attributes || {} })) : null; @@ -57,10 +58,14 @@ const PointaAnnotationFactory = { // Essential context - stripped down element_context: { tag: context.tag, + id: context.id || null, + stable_attributes: context.stable_attributes || {}, classes: classes, text: context.text, // Keep full text (100 chars) for fallback matching styles: minimalStyles, - position: context.position // Keep position for fallback element finding + position: context.position, // Keep position for fallback element finding + sibling_index: context.sibling_index, + same_tag_index: context.same_tag_index }, // Critical for finding the right code @@ -100,10 +105,14 @@ const PointaAnnotationFactory = { viewport: context.viewport, element_context: { tag: context.tag, + id: context.id || null, + stable_attributes: context.stable_attributes || {}, classes: context.classes, text: context.text, styles: context.styles, - position: context.position + position: context.position, + sibling_index: context.sibling_index, + same_tag_index: context.same_tag_index }, source_file_path: context.source_mapping?.source_file_path || null, source_line_range: context.source_mapping?.source_line_range || null, @@ -208,6 +217,7 @@ const PointaAnnotationFactory = { tag: parent.tag, classes: parent.classes || [], id: parent.id || null, + stable_attributes: parent.stable_attributes || {}, role: parent.role || null })) : []; @@ -243,9 +253,13 @@ const PointaAnnotationFactory = { // ENHANCED: Full element context for design mode element_context: { tag: context.tag, + id: context.id || null, + stable_attributes: context.stable_attributes || {}, classes: context.classes, // Keep ALL classes (important for framework detection) text: context.text, position: context.position, + sibling_index: context.sibling_index, + same_tag_index: context.same_tag_index, // Full computed styles BEFORE changes (critical for AI) computed_styles: fullComputedStyles diff --git a/extension/content/modules/annotation-mode.js b/extension/content/modules/annotation-mode.js index 203d637..8c12a56 100644 --- a/extension/content/modules/annotation-mode.js +++ b/extension/content/modules/annotation-mode.js @@ -1393,8 +1393,12 @@ class PointaAnnotationMode { // Request full page screenshot from background const response = await chrome.runtime.sendMessage({ action: 'captureScreenshot' }); - if (!response.success) { - throw new Error('Failed to capture screenshot'); + if (!response?.success) { + const message = response?.error || 'Failed to capture screenshot'; + const error = new Error(message); + error.code = response?.code || 'SCREENSHOT_CAPTURE_FAILED'; + error.details = response?.details || null; + throw error; } // Create canvas to crop selection @@ -1467,7 +1471,7 @@ class PointaAnnotationMode { } catch (error) { console.error('[Pointa Screenshot] Error:', error); - alert('Failed to capture screenshot. Please try again.'); + alert(error.message || 'Failed to capture screenshot. Please try again.'); this.exitScreenshotSelectionMode(); } } @@ -1885,4 +1889,4 @@ class PointaAnnotationMode { } // Make class available globally -window.PointaAnnotationMode = PointaAnnotationMode; \ No newline at end of file +window.PointaAnnotationMode = PointaAnnotationMode; diff --git a/extension/content/modules/bug-recorder.js b/extension/content/modules/bug-recorder.js index 19d9e6a..a3423a8 100644 --- a/extension/content/modules/bug-recorder.js +++ b/extension/content/modules/bug-recorder.js @@ -26,6 +26,21 @@ const BugRecorder = { captureStdout: false, // Whether to capture full terminal output (stdout/stderr) backendLogStatus: null, // Current backend log connection status + // Page-level network fallback used when CDP/debugger data is unavailable. + cdpRecordingActive: false, + networkInstrumentationActive: false, + networkInstrumentationId: null, + networkInstrumentationEventName: null, + networkInstrumentationStopEventName: null, + networkInstrumentationMessageHandler: null, + networkInstrumentationEvents: [], + consoleInstrumentationActive: false, + consoleInstrumentationId: null, + consoleInstrumentationEventName: null, + consoleInstrumentationStopEventName: null, + consoleInstrumentationMessageHandler: null, + consoleInstrumentationEvents: [], + // ========== TOKEN OPTIMIZATION HELPERS ========== /** @@ -109,14 +124,13 @@ const BugRecorder = { const method = event.data?.method || ''; // Filter out Pointa's own health check requests - if (url.includes('127.0.0.1:4242/health')) return true; - if (url.includes('localhost:4242/health')) return true; + if (window.PointaBrowser.isLocalServerHealthUrl(url)) return true; // Filter out OPTIONS/preflight requests (CORS preflight) if (method === 'OPTIONS') return true; // Filter out Pointa backend recording API calls - if (url.includes('127.0.0.1:4242/api/backend')) return true; + if (window.PointaBrowser.isLocalServerBackendUrl(url)) return true; return false; }, @@ -130,6 +144,343 @@ const BugRecorder = { return message.substring(0, maxLength) + '...'; }, + /** + * Start reversible page-level fetch/XHR instrumentation. + * Events are buffered separately and merged only when CDP has no network data. + */ + async startNetworkInstrumentation() { + await this.stopNetworkInstrumentation(); + + this.networkInstrumentationEvents = []; + this.networkInstrumentationId = `pointa_network_${Date.now()}_${Math.random().toString(36).slice(2)}`; + + const instrumentationId = this.networkInstrumentationId; + const eventName = `pointa-network-recorder-event-${instrumentationId}`; + const stopEventName = `pointa-network-recorder-stop-${instrumentationId}`; + this.networkInstrumentationEventName = eventName; + this.networkInstrumentationStopEventName = stopEventName; + this.networkInstrumentationActive = false; + + this.networkInstrumentationMessageHandler = (event) => { + let message = null; + + try { + message = JSON.parse(event.detail); + } catch (_) { + return; + } + + if (!message || message.id !== instrumentationId) return; + if (message.ready) { + this.networkInstrumentationActive = true; + return; + } + if (message.error) { + console.warn('[BugRecorder] Page network instrumentation failed:', message.error); + return; + } + if (!this.isRecording || !message.event) return; + + this.captureNetworkInstrumentationEvent(message.event); + }; + + window.addEventListener(eventName, this.networkInstrumentationMessageHandler); + + const config = { + id: instrumentationId, + eventName, + stopEventName + }; + + try { + const response = await chrome.runtime.sendMessage({ + action: 'startPageNetworkInstrumentation', + config + }); + + if (response?.success) { + return true; + } + + console.warn('[BugRecorder] MAIN-world network instrumentation failed:', response?.error); + } catch (error) { + console.warn('[BugRecorder] Could not start MAIN-world network instrumentation:', error.message); + } + + window.removeEventListener(eventName, this.networkInstrumentationMessageHandler); + this.networkInstrumentationMessageHandler = null; + this.networkInstrumentationActive = false; + this.networkInstrumentationId = null; + this.networkInstrumentationEventName = null; + this.networkInstrumentationStopEventName = null; + this.networkInstrumentationEvents = []; + return false; + }, + + /** + * Stop page-level network instrumentation and return buffered events. + */ + async stopNetworkInstrumentation() { + const events = this.networkInstrumentationEvents || []; + + if (this.networkInstrumentationId) { + const config = { + id: this.networkInstrumentationId, + stopEventName: this.networkInstrumentationStopEventName || `pointa-network-recorder-stop-${this.networkInstrumentationId}` + }; + + try { + const response = await chrome.runtime.sendMessage({ + action: 'stopPageNetworkInstrumentation', + config + }); + + if (!response?.success) { + window.dispatchEvent(new Event(config.stopEventName)); + } + } catch (_) { + window.dispatchEvent(new Event(config.stopEventName)); + } + } + + if (this.networkInstrumentationMessageHandler && this.networkInstrumentationEventName) { + window.removeEventListener(this.networkInstrumentationEventName, this.networkInstrumentationMessageHandler); + } + + this.networkInstrumentationActive = false; + this.networkInstrumentationId = null; + this.networkInstrumentationEventName = null; + this.networkInstrumentationStopEventName = null; + this.networkInstrumentationMessageHandler = null; + this.networkInstrumentationEvents = []; + + return events; + }, + + /** + * Normalize a page-instrumented network event into the existing timeline shape. + */ + captureNetworkInstrumentationEvent(event) { + if (!event || event.type !== 'network') return; + + const data = event.data || {}; + const observedAt = typeof event.observedAt === 'number' ? event.observedAt : Date.now(); + + this.networkInstrumentationEvents.push({ + timestamp: event.timestamp || new Date(observedAt).toISOString(), + relativeTime: Math.max(0, observedAt - this.startTime), + type: 'network', + subtype: event.subtype === 'failed' ? 'failed' : 'success', + severity: event.severity === 'error' || event.subtype === 'failed' ? 'error' : 'info', + data: { + url: data.url || 'unknown', + method: String(data.method || 'GET').toUpperCase(), + status: data.status, + statusText: data.statusText, + error: data.error, + ok: data.ok, + resourceType: data.resourceType || data.type || 'Fetch', + type: data.type || 'content-network', + requestId: data.requestId, + duration: data.duration + } + }); + }, + + normalizeNetworkUrlForComparison(url) { + if (!url) return ''; + + try { + return new URL(url, window.location.href).href; + } catch (_) { + return String(url); + } + }, + + isDuplicateNetworkEvent(existingEvent, candidateEvent) { + const existingData = existingEvent.data || {}; + const candidateData = candidateEvent.data || {}; + const existingStatus = typeof existingData.status === 'undefined' ? '' : String(existingData.status); + const candidateStatus = typeof candidateData.status === 'undefined' ? '' : String(candidateData.status); + + return existingEvent.subtype === candidateEvent.subtype && + String(existingData.method || '').toUpperCase() === String(candidateData.method || '').toUpperCase() && + this.normalizeNetworkUrlForComparison(existingData.url) === this.normalizeNetworkUrlForComparison(candidateData.url) && + existingStatus === candidateStatus && + Math.abs((existingEvent.relativeTime || 0) - (candidateEvent.relativeTime || 0)) < 500; + }, + + mergeNetworkEvents(cdpNetworkEvents, fallbackNetworkEvents) { + if (!cdpNetworkEvents.length) return fallbackNetworkEvents; + if (!fallbackNetworkEvents.length) return cdpNetworkEvents; + + const merged = [...cdpNetworkEvents]; + fallbackNetworkEvents.forEach((fallbackEvent) => { + const isDuplicate = merged.some((existingEvent) => + this.isDuplicateNetworkEvent(existingEvent, fallbackEvent) + ); + + if (!isDuplicate) { + merged.push(fallbackEvent); + } + }); + + return merged; + }, + + async startConsoleInstrumentation() { + await this.stopConsoleInstrumentation(); + + this.consoleInstrumentationEvents = []; + this.consoleInstrumentationId = `pointa_console_${Date.now()}_${Math.random().toString(36).slice(2)}`; + + const instrumentationId = this.consoleInstrumentationId; + const eventName = `pointa-console-recorder-event-${instrumentationId}`; + const stopEventName = `pointa-console-recorder-stop-${instrumentationId}`; + this.consoleInstrumentationEventName = eventName; + this.consoleInstrumentationStopEventName = stopEventName; + this.consoleInstrumentationActive = false; + + this.consoleInstrumentationMessageHandler = (event) => { + let message = null; + + try { + message = JSON.parse(event.detail); + } catch (_) { + return; + } + + if (!message || message.id !== instrumentationId) return; + if (message.ready) { + this.consoleInstrumentationActive = true; + return; + } + if (message.error) { + console.warn('[BugRecorder] Page console instrumentation failed:', message.error); + return; + } + if (!this.isRecording || !message.event) return; + + this.captureConsoleInstrumentationEvent(message.event); + }; + + window.addEventListener(eventName, this.consoleInstrumentationMessageHandler); + + const config = { + id: instrumentationId, + eventName, + stopEventName + }; + + try { + const response = await chrome.runtime.sendMessage({ + action: 'startPageConsoleInstrumentation', + config + }); + + if (response?.success) { + return true; + } + + console.warn('[BugRecorder] MAIN-world console instrumentation failed:', response?.error); + } catch (error) { + console.warn('[BugRecorder] Could not start MAIN-world console instrumentation:', error.message); + } + + this.cleanupConsoleInstrumentationState(); + return false; + }, + + async stopConsoleInstrumentation() { + const events = this.consoleInstrumentationEvents || []; + + if (this.consoleInstrumentationId) { + const config = { + id: this.consoleInstrumentationId, + stopEventName: this.consoleInstrumentationStopEventName || `pointa-console-recorder-stop-${this.consoleInstrumentationId}` + }; + + try { + const response = await chrome.runtime.sendMessage({ + action: 'stopPageConsoleInstrumentation', + config + }); + + if (!response?.success) { + window.dispatchEvent(new Event(config.stopEventName)); + } + } catch (_) { + window.dispatchEvent(new Event(config.stopEventName)); + } + } + + this.cleanupConsoleInstrumentationState(); + return events; + }, + + cleanupConsoleInstrumentationState() { + if (this.consoleInstrumentationMessageHandler && this.consoleInstrumentationEventName) { + window.removeEventListener(this.consoleInstrumentationEventName, this.consoleInstrumentationMessageHandler); + } + + this.consoleInstrumentationActive = false; + this.consoleInstrumentationId = null; + this.consoleInstrumentationEventName = null; + this.consoleInstrumentationStopEventName = null; + this.consoleInstrumentationMessageHandler = null; + this.consoleInstrumentationEvents = []; + }, + + captureConsoleInstrumentationEvent(event) { + if (!event || !event.type || !event.type.startsWith('console')) return; + + const data = event.data || {}; + const observedAt = typeof event.observedAt === 'number' ? event.observedAt : Date.now(); + + this.consoleInstrumentationEvents.push({ + timestamp: event.timestamp || new Date(observedAt).toISOString(), + relativeTime: Math.max(0, observedAt - this.startTime), + type: event.type, + subtype: event.subtype, + severity: event.severity || (event.type === 'console-error' ? 'error' : event.type === 'console-warning' ? 'warning' : 'info'), + data: { + level: data.level || (event.type === 'console-error' ? 'error' : event.type === 'console-warning' ? 'warn' : 'log'), + message: data.message || '', + source: data.source || window.location.href, + url: data.url || window.location.href, + lineNumber: data.lineNumber, + columnNumber: data.columnNumber, + stack: data.stack, + reason: data.reason, + capturedBy: data.capturedBy || 'page-console-recorder' + } + }); + }, + + isDuplicateConsoleEvent(existingEvent, candidateEvent) { + return existingEvent.type === candidateEvent.type && + (existingEvent.data?.message || '') === (candidateEvent.data?.message || '') && + Math.abs((existingEvent.relativeTime || 0) - (candidateEvent.relativeTime || 0)) < 500; + }, + + mergeConsoleEvents(cdpConsoleEvents, fallbackConsoleEvents) { + if (!cdpConsoleEvents.length) return fallbackConsoleEvents; + if (!fallbackConsoleEvents.length) return cdpConsoleEvents; + + const merged = [...cdpConsoleEvents]; + fallbackConsoleEvents.forEach((fallbackEvent) => { + const isDuplicate = merged.some((existingEvent) => + this.isDuplicateConsoleEvent(existingEvent, fallbackEvent) + ); + + if (!isDuplicate) { + merged.push(fallbackEvent); + } + }); + + return merged; + }, + /** * Clean and optimize a console event */ @@ -159,9 +510,13 @@ const BugRecorder = { let repeatCount = 0; for (const event of events) { + const lastMessage = lastEvent?.data?.message; + const eventMessage = event.data?.message; const isSameMessage = lastEvent && lastEvent.type === event.type && - lastEvent.data?.message === event.data?.message && + typeof lastMessage === 'string' && + lastMessage.length > 0 && + lastMessage === eventMessage && Math.abs(event.relativeTime - lastEvent.relativeTime) < 100; if (isSameMessage) { @@ -268,17 +623,41 @@ const BugRecorder = { // Screenshot will be captured at the END of recording to show the bug state + // Start the page-level network fallback up front. It is merged only if CDP + // is unavailable, fails, or returns no network events. + const networkInstrumentationStarted = await this.startNetworkInstrumentation(); + if (!networkInstrumentationStarted) { + console.warn('[BugRecorder] Page network instrumentation could not be started'); + } + + // Start page-level console instrumentation for browsers without CDP. + const consoleInstrumentationStarted = await this.startConsoleInstrumentation(); + if (!consoleInstrumentationStarted) { + console.warn('[BugRecorder] Page console instrumentation could not be started'); + } + // Start CDP recording via background script // This captures network and console via Chrome DevTools Protocol (CDP) // which runs in the page's MAIN world, capturing ALL requests and console messages - try { - const response = await chrome.runtime.sendMessage({ action: 'startCDPRecording' }); - if (!response.success) { - console.warn('[BugRecorder] CDP recording failed to start:', response.error); - // Continue anyway - we'll still capture interactions and error events + const hasDebugger = Boolean( + window.PointaBrowser && + typeof window.PointaBrowser.hasCapability === 'function' && + window.PointaBrowser.hasCapability('debugger') + ); + this.cdpRecordingActive = false; + + if (hasDebugger) { + try { + const response = await chrome.runtime.sendMessage({ action: 'startCDPRecording' }); + if (response.success) { + this.cdpRecordingActive = true; + } else { + console.warn('[BugRecorder] CDP recording failed to start:', response.error); + // Continue anyway - page-level fallback will cover fetch/XHR metadata + } + } catch (error) { + console.warn('[BugRecorder] Could not start CDP recording:', error.message); } - } catch (error) { - console.warn('[BugRecorder] Could not start CDP recording:', error.message); } // Start backend log recording if enabled @@ -330,36 +709,60 @@ const BugRecorder = { } this.isRecording = false; + const fallbackNetworkEvents = await this.stopNetworkInstrumentation(); + const fallbackConsoleEvents = await this.stopConsoleInstrumentation(); + let cdpNetworkEvents = []; + let cdpConsoleEvents = []; // Stop CDP recording and get captured events - try { - const response = await chrome.runtime.sendMessage({ action: 'stopCDPRecording' }); - if (response.success) { - // Merge CDP events into recording data - // CDP captures network and console from the page's MAIN world - if (response.events.network) { - this.recordingData.network = [ - ...this.recordingData.network, - ...response.events.network - ]; - } - if (response.events.console) { - this.recordingData.console = [ - ...this.recordingData.console, - ...response.events.console - ]; + if (this.cdpRecordingActive) { + try { + const response = await chrome.runtime.sendMessage({ action: 'stopCDPRecording' }); + if (response.success) { + // CDP captures network and console from the page's MAIN world + cdpNetworkEvents = response.events.network || []; + cdpConsoleEvents = response.events.console || []; + } else { + console.warn('[BugRecorder] CDP recording stop failed:', response.error); } - console.log('[BugRecorder] Merged CDP events:', { - network: response.events.network?.length || 0, - console: response.events.console?.length || 0 - }); - } else { - console.warn('[BugRecorder] CDP recording stop failed:', response.error); + } catch (error) { + console.warn('[BugRecorder] Could not stop CDP recording:', error.message); + } finally { + this.cdpRecordingActive = false; } - } catch (error) { - console.warn('[BugRecorder] Could not stop CDP recording:', error.message); } + const networkEventsToMerge = this.mergeNetworkEvents(cdpNetworkEvents, fallbackNetworkEvents); + const networkSource = cdpNetworkEvents.length > 0 + ? (networkEventsToMerge.length > cdpNetworkEvents.length ? 'cdp+content-fetch-xhr' : 'cdp') + : 'content-fetch-xhr'; + if (networkEventsToMerge.length > 0) { + this.recordingData.network = [ + ...this.recordingData.network, + ...networkEventsToMerge + ]; + } + + const consoleEventsToMerge = this.mergeConsoleEvents(cdpConsoleEvents, fallbackConsoleEvents); + const consoleSource = cdpConsoleEvents.length > 0 + ? (consoleEventsToMerge.length > cdpConsoleEvents.length ? 'cdp+page-console' : 'cdp') + : 'page-console'; + if (consoleEventsToMerge.length > 0) { + this.recordingData.console = [ + ...this.recordingData.console, + ...consoleEventsToMerge + ]; + } + + console.log('[BugRecorder] Merged recording events:', { + network: networkEventsToMerge.length, + networkSource, + fallbackNetwork: fallbackNetworkEvents.length, + console: consoleEventsToMerge.length, + consoleSource, + fallbackConsole: fallbackConsoleEvents.length + }); + // Stop backend log recording and get captured logs if (this.includeBackendLogs) { try { @@ -840,14 +1243,19 @@ const BugRecorder = { // Find network failures const networkFailures = events.filter((e) => e.type === 'network' && e.subtype === 'failed'); networkFailures.forEach((failure) => { + const statusOrError = failure.data.status + ? `status ${failure.data.status}` + : failure.data.error || 'unknown reason'; + keyIssues.push({ type: 'network-failure', - description: `${failure.data.method} ${failure.data.url} failed with status ${failure.data.status || 'unknown'}`, + description: `${failure.data.method} ${failure.data.url} failed with ${statusOrError}`, timestamp: failure.timestamp, relativeTime: failure.relativeTime, severity: 'error', url: failure.data.url, status: failure.data.status, + error: failure.data.error, responseBody: failure.data.responseBody }); }); diff --git a/extension/content/modules/bug-replay-engine.js b/extension/content/modules/bug-replay-engine.js index 6e0e6a6..32be1db 100644 --- a/extension/content/modules/bug-replay-engine.js +++ b/extension/content/modules/bug-replay-engine.js @@ -253,13 +253,14 @@ const BugReplayEngine = { const modal = document.createElement('div'); modal.className = 'pointa-comment-modal bug-replay-success-modal'; const promptText = `Check new bug report with console logs for ${bugId}`; + const promptValue = PointaUtils.escapeAttribute(promptText); modal.innerHTML = `

✅ Auto-Replay Complete!

New recording captured with updated logs.

Next step: Copy prompt and paste into your AI coding tool.

- +
@@ -292,23 +293,25 @@ const BugReplayEngine = { const modal = document.createElement('div'); modal.className = 'pointa-comment-modal bug-replay-failed-modal'; - const originalSteps = this.extractSteps(bugReport.recordings[0]); + const originalSteps = this.extractSteps(bugReport?.recordings?.[0]); + const errorMessage = PointaUtils.escapeHtml(error?.message || 'Auto-replay could not complete.'); + const iteration = Number(bugReport?.recordings?.length || 0) + 1; modal.innerHTML = `

⚠️ Auto-Replay Failed

-

${error.message}

+

${errorMessage}

Some elements may have changed. Please record manually following these steps:

Original Steps:

    - ${originalSteps.map(step => `
  1. ${step}
  2. `).join('')} + ${originalSteps.map(step => `
  3. ${PointaUtils.escapeHtml(step)}
  4. `).join('')}
@@ -330,10 +333,10 @@ const BugReplayEngine = { * Extract steps from recording for display */ extractSteps(recording) { - return recording.timeline.events + return (recording?.timeline?.events || []) .filter(e => e.type === 'user-interaction') .map((e, i) => { - const elem = e.data.element; + const elem = e.data?.element || {}; const action = e.subtype === 'click' ? 'Click' : e.subtype === 'input' ? 'Type in' : e.subtype; const target = elem.textContent || elem.id || `${elem.tagName}${elem.className ? '.' + elem.className : ''}`; return `${action} "${target}"`; diff --git a/extension/content/modules/bug-report-ui.js b/extension/content/modules/bug-report-ui.js index 7ca2878..cb29add 100644 --- a/extension/content/modules/bug-report-ui.js +++ b/extension/content/modules/bug-report-ui.js @@ -15,8 +15,9 @@ const BugReportUI = { */ formatBugId(bugId) { // Extract timestamp from bug ID (e.g., "BUG-1763347240602" -> 1763347240602) - const match = bugId.match(/BUG-(\d+)/); - if (!match) return bugId; + const idText = bugId == null ? '' : String(bugId); + const match = idText.match(/BUG-(\d+)/); + if (!match) return this.escapeHtml(idText); const timestamp = parseInt(match[1], 10); const date = new Date(timestamp); @@ -32,7 +33,7 @@ const BugReportUI = { }; const friendlyDate = date.toLocaleString('en-US', options); - return `${bugId} (${friendlyDate})`; + return `${this.escapeHtml(idText)} (${friendlyDate})`; }, /** @@ -282,18 +283,20 @@ const BugReportUI = { return `Clicked "${this.escapeHtml(desc)}"`; } if (event.subtype === 'input') { - return `Input to ${event.data.element.tagName}`; + return `Input to ${this.escapeHtml(event.data.element.tagName)}`; } if (event.subtype === 'keypress') { - return `Pressed ${event.data.key}`; + return `Pressed ${this.escapeHtml(event.data.key)}`; } return 'User interaction'; case 'network': - const statusOrError = event.data.status || event.data.error || 'Network Error'; + const method = this.escapeHtml(event.data.method || 'GET'); + const url = this.escapeHtml(this.truncateUrl(event.data.url || '')); + const statusOrError = this.escapeHtml(event.data.status || event.data.error || 'Network Error'); if (event.subtype === 'failed') { - return `${event.data.method} ${this.truncateUrl(event.data.url)} - Failed (${statusOrError})`; + return `${method} ${url} - Failed (${statusOrError})`; } - return `${event.data.method} ${this.truncateUrl(event.data.url)} - ${event.data.status}`; + return `${method} ${url} - ${this.escapeHtml(event.data.status || '')}`; case 'console-error': return this.escapeHtml(this.truncateText(event.data.message, 100)); case 'console-warning': @@ -422,7 +425,7 @@ const BugReportUI = {

Tell your AI:

- "Analyze and fix bug report ${bugReportId}" + "Analyze and fix bug report ${this.escapeHtml(bugReportId)}"
-
http://127.0.0.1:4242/mcp
+
${mcpHttpUrl}

HTTP endpoint (requires manual server start)

@@ -373,6 +374,26 @@ const PointaOnboarding = { } }, 400); }, + + /** + * Skip onboarding without advancing through setup screens. + */ + async skip() { + await chrome.storage.local.set({ + onboardingCompleted: true, + onboardingSkipped: true + }); + + this.hide(); + }, + + getMcpHttpUrl() { + if (window.PointaBrowser && typeof window.PointaBrowser.getLocalServerUrl === 'function') { + return window.PointaBrowser.getLocalServerUrl('/mcp'); + } + + return 'http://127.0.0.1:4242/mcp'; + }, /** * Build HTML for specific step @@ -436,7 +457,7 @@ const PointaOnboarding = {
@@ -522,6 +543,7 @@ pointa-server start @@ -541,6 +563,7 @@ pointa-server start * Step 2: MCP Setup */ buildMCPStep() { + const selectedAgent = this.selectedAgent; return `
@@ -551,21 +574,22 @@ pointa-server start
- - - - - + + + + +
-

Select your AI coding tool above

+ ${selectedAgent ? this.getAgentInstructions(selectedAgent) : '

Select your AI coding tool above

'}
@@ -630,6 +654,7 @@ pointa-server start @@ -790,7 +815,7 @@ pointa-server start const skipBtn = this.overlay.querySelector('.skip-btn'); if (skipBtn) { skipBtn.addEventListener('click', () => { - this.complete(); + this.skip(); }); } diff --git a/extension/content/modules/performance-report-ui.js b/extension/content/modules/performance-report-ui.js index 31247d5..74630fd 100644 --- a/extension/content/modules/performance-report-ui.js +++ b/extension/content/modules/performance-report-ui.js @@ -13,8 +13,9 @@ const PerformanceReportUI = { * Format performance report ID in human-friendly way */ formatPerfId(perfId) { - const match = perfId.match(/PERF-(\d+)/); - if (!match) return perfId; + const idText = perfId == null ? '' : String(perfId); + const match = idText.match(/PERF-(\d+)/); + if (!match) return this.escapeHtml(idText); const timestamp = parseInt(match[1], 10); const date = new Date(timestamp); @@ -29,7 +30,7 @@ const PerformanceReportUI = { }; const friendlyDate = date.toLocaleString('en-US', options); - return `${perfId} (${friendlyDate})`; + return `${this.escapeHtml(idText)} (${friendlyDate})`; }, /** @@ -233,24 +234,24 @@ const PerformanceReportUI = {
CPU Cores
-
${deviceInfo.cpuCores}
+
${this.escapeHtml(deviceInfo.cpuCores)}
Device Memory
-
${deviceInfo.deviceMemory}
+
${this.escapeHtml(deviceInfo.deviceMemory)}
${connection ? `
Connection Type
-
${connection.effectiveType || 'unknown'}
-
${connection.downlink || 'N/A'}
+
${this.escapeHtml(connection.effectiveType || 'unknown')}
+
${this.escapeHtml(connection.downlink || 'N/A')}
Network RTT
-
${connection.rtt || 'N/A'}
+
${this.escapeHtml(connection.rtt || 'N/A')}
` : ''}
@@ -417,10 +418,10 @@ const PerformanceReportUI = { return `
- ${resource.type} - ${resource.duration}ms + ${this.escapeHtml(resource.type)} + ${this.escapeHtml(resource.duration)}ms
-
${this.truncateUrl(resource.name)}
+
${this.escapeHtml(this.truncateUrl(resource.name))}
${resource.size ? `${this.formatBytes(resource.size)}` : ''} ${resource.cached ? '✓ cached' : 'not cached'} @@ -466,10 +467,10 @@ const PerformanceReportUI = { return `Clicked "${this.escapeHtml(desc)}"`; } if (event.subtype === 'input') { - return `Input to ${event.data.element.tagName}`; + return `Input to ${this.escapeHtml(event.data.element.tagName)}`; } if (event.subtype === 'scroll') { - return `Scrolled to ${event.data.scrollY}px`; + return `Scrolled to ${this.escapeHtml(event.data.scrollY)}px`; } return 'User interaction'; default: @@ -509,7 +510,7 @@ const PerformanceReportUI = {

Tell your AI:

- "Analyze and fix performance report ${perfReportId}" + "Analyze and fix performance report ${this.escapeHtml(perfReportId)}"