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 || pwd)"
+cd "$root"
+
+python_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
+
+ 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 Primary research question
+ --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:-}"
+
+mkdir -p "$research_dir"
+
+cat > "$research_dir/task_plan.md" < "$research_dir/findings.md" <
+
+## Observations
+
+-
+
+## Options Considered
+
+| Option | Pros | Cons | Decision |
+| --- | --- | --- | --- |
+
+## Fold-Forward Candidates
+
+| Finding | Target Artifact | Proposed Change |
+| --- | --- | --- |
+
+## Open Questions
+
+-
+FINDINGS
+
+cat > "$research_dir/progress.md" <
+
@@ -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
✅ Auto-Replay Complete!
New recording captured with updated logs.
Next step: Copy prompt and paste into your AI coding tool.
⚠️ Auto-Replay Failed
-${error.message}
+${errorMessage}
Some elements may have changed. Please record manually following these steps:
Original Steps:
- ${originalSteps.map(step => `- ${step}
`).join('')}
+ ${originalSteps.map(step => `- ${PointaUtils.escapeHtml(step)}
`).join('')}
Tell your AI:
"Analyze and fix bug report ${bugReportId}"+"Analyze and fix bug report ${this.escapeHtml(bugReportId)}"Select your AI coding tool above
+ ${selectedAgent ? this.getAgentInstructions(selectedAgent) : 'Select your AI coding tool above
'}Tell your AI:
"Analyze and fix performance report ${perfReportId}"+"Analyze and fix performance report ${this.escapeHtml(perfReportId)}"