Skill: arc-claw-bot/fulcra-annotations (version 1.0.11)

--- name: fulcra-annotations description: Create, list, update, and record Fulcra annotations through the Fulcra Life API. Use when a user asks to log an annotation, create an annotation button/definition, record a moment/boolean/numeric/scale annotation, inspect annotation IDs/source IDs, or build agent workflows that write Fulcra annotation events. --- # Fulcra Annotations Use this skill when the user wants an agent to create, record, or verify Fulcra annotations. Fulcra gives agents and their humans scoped, secure access to read and write real-world context and shared human/agent memory: attention, events, location, calendar, health, wearables, and other streams. Use this skill for the write side of that loop: creating reusable annotation definitions and recording user-approved moments or values. Agents should use the bundled script first. Do not hand-write curl calls unless the script is missing a required capability, because the script keeps tokens out of chat, builds the Fulcra ingest payload consistently, and performs readback verification. ## First-Run Onboarding Pattern When a user is new to Fulcra annotations, optimize for a quick useful loop: choose a concrete thing to track, create one or two definitions, record one real data point, verify it, and show the user what now exists. 1. Start with a short, grounded prompt. Do not ask only "what do you want to track?"; offer 2-3 specific options based on the user's context, such as a daily focus score, coffee count, symptom check, workout effort, or a moment log for important events. 2. Check auth only after there is a clear use case. If `uv tool run fulcra-api user-info` fails, run `uv tool run fulcra-api auth login`, keep the process alive, and send only the device URL/code through the trusted user channel. 3. Translate the use case into 1-3 annotation definitions. Prefer the simplest type that captures the signal: `moment` for occurrences, `boolean` for yes/no, `numeric` for counts or measured quantities, and `scale` for subjective ratings. 4. Run `list` before `create` to avoid duplicates. If creating multiple definitions, save the returned `annotation.id`, `source_id`, and type from each create result in your working notes so the next record step does not need another lookup. 5. Ask one direct question for the first record, then call `record --id ...` with `--value` when needed. Treat success as confirmed only when `verified_matches >= 1`. 6. For the handoff, summarize the definition names, the exact timestamp written, and one natural next action. If generating a demo artifact, use synthetic or explicitly approved real data and keep private records out of chat. ## Core Concepts - **Annotation definition**: the reusable button/metric definition, such as `Focus` or `Asked Agent to Do Something New`. Created once with `create`. - **Annotation record**: one logged occurrence/value of a definition. Written with `record`. - **Moment annotation**: an event with no value. Use for "this happened" logs. - **Boolean/numeric/scale annotations**: metric-like records that require `--value`. - **Definition tags**: reusable labels stored on the annotation definition. Add them when creating the definition with repeated `create --tag`. - **Record tags**: labels stored on one specific record. Add them when recording with repeated `record --tag`. - **Tag resolution**: Fulcra stores tag IDs. The script accepts tag names or UUIDs, resolves names to IDs, and creates missing tag names automatically. - **Historical record**: any record whose event time is not now. Always pass `--recorded-at`. - **Confirmed write**: a record is not confirmed until readback finds `verified_matches >= 1`. ## Safety Rules - Never print access tokens, refresh tokens, raw private Fulcra records, credential files, or direct capability URLs in chat. - Device auth URLs and user codes are allowed only when the intended user needs to approve Fulcra access from another device; send them only through the active trusted user channel. - Ask before deleting or updating an existing annotation definition. - For public demos, use synthetic annotation names/values unless the user explicitly approves real data. - Do not claim a write succeeded from HTTP status alone. Check the script result and verify readback. - Duration annotations are only partially supported by this skill; prefer moment/boolean/numeric/scale until Fulcra documents duration ingest shape more clearly. ## Auth The script gets auth from a trusted secret manager token when `FULCRA_ACCESS_TOKEN` is set, otherwise from the locally authenticated Fulcra CLI command configured by `FULCRA_CLI_COMMAND`. Fulcra requires an authenticated account, not an API key. Accounts can be created through the CLI auth flow and include 5 GB of storage free forever. Users who want biometrics, location, calendar, and other mobile context can install the Context iOS app and sign in with the same account; the app uses the same free storage and is no longer subscription gated. Android is coming soon. Authenticate first: ```bash uv tool run fulcra-api auth login ``` For remote agents, keep the CLI running, surface the printed device authorization URL and code to the intended user in chat through the active trusted user channel, and wait for approval. The user can open the URL from any browser on any device. After approval, verify auth with a non-token command such as `uv tool run fulcra-api user-info`; do not paste tokens into chat. Set `FULCRA_HOME=/path/to/home` if credentials are not under the process `HOME`. ## Common Commands List annotation definitions before creating a new one: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py list ``` Create a moment annotation definition: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py create \ --type moment \ --name "Asked Agent to Do Something New" \ --description "Logged when the user asks the agent to try a new category of work" \ --tag agent \ --tag new-task ``` Use `create --tag` more than once to attach multiple tags to the definition. Definition tags should be short, stable labels such as `agent`, `health`, or `research`. The script resolves tag names to Fulcra tag IDs before sending the API payload. Record a moment annotation now: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py record \ --name "Asked Agent to Do Something New" \ --note "User asked for a new annotation workflow" \ --tag new-task ``` Use `record --tag` more than once to attach tags to the individual record. If `record --tag` is omitted, the record inherits the definition tags. If `record --tag` is present, those explicit record tags are resolved to Fulcra tag IDs and sent for that record. Record a historical moment annotation: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py record \ --name "Asked Agent to Do Something New" \ --recorded-at "2026-05-15T10:00:00-04:00" \ --note "User asked for a new annotation workflow" ``` Use a full ISO-8601 timestamp with timezone for historical writes. If the user says "yesterday at 10am", resolve it in the user's timezone and pass the offset explicitly, for example `2026-05-15T10:00:00-04:00`. Fulcra readback may show the equivalent UTC time, such as `2026-05-15T14:00:00+00:00`. Record by annotation ID when names are ambiguous: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py record \ --id "<annotation-id>" \ --note "Logged from automation" ``` Create a 1-5 scale annotation definition: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py create \ --type scale \ --name "Focus" \ --description "How focused do I feel right now?" \ --scale-labels "1=Scattered,2=Low,3=Neutral,4=Focused,5=Locked In" \ --default-value 3 ``` Create a numeric count annotation: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py create \ --type numeric \ --name "Coffee Count" \ --description "Number of coffees consumed today" \ --measurement-type count \ --tag health \ --tag intake ``` For measurements with specialized units, inspect the live schema through the bundled helper before creating the definition and then prefer adding script support over ad hoc API calls: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py measurement-schema ``` Use `measurement-schema --raw` only for local debugging. Do not paste raw schema output into chat if it includes private account context. Record a scale/numeric/boolean value: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py record \ --name "Focus" \ --value 4 \ --note "Deep work block started well" ``` Read back recent records for verification: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py recent \ --name "Asked Agent to Do Something New" \ --hours 72 \ --limit 20 ``` Update definition metadata or replace definition tags only after user approval: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py update \ --id "<annotation-id>" \ --description "Updated description" \ --tag workflow \ --tag agent ``` Delete a definition only after user approval: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py delete \ --id "<annotation-id>" ``` Dry-run any write before sending it: ```bash python3 skills/fulcra-annotations/scripts/fulcra_annotations.py record \ --name "Focus" --value 4 --dry-run ``` ## Workflow 1. Determine the annotation type: moment, boolean, numeric, or scale. Prefer moment for "this happened". 2. Decide whether tags belong on the definition, the individual record, or both. 3. Run `list` and check whether the definition already exists. 4. If an existing definition needs a metadata or tag change, ask for approval and use `update --id ...`; dry-run first for broad retagging. 5. If the definition is missing and the user asked to create/log that annotation, run `create`. 6. Record with `record --name ...` or `record --id ...`. 7. For historical writes, always include `--recorded-at "<ISO-8601 timestamp with timezone>"`. 8. Trust only a successful script result with `verified_matches >= 1` for confirmed writes. 9. If verification fails after ingest returns `204`, wait briefly and rerun `recent --id <annotation-id>` or `recent --name "<name>" --hours <window>`. 10. Report the exact timestamp written and the readback timestamp. Do not include tokens, direct capability URLs, or unnecessary private record data. ## Demo and Handoff Pattern For onboarding, the first verified record should lead to a small payoff instead of a dry "done" message. - Offer 2-3 visual directions and ask the user to choose before generating a dashboard or artifact. - Keep demo files local to the workspace and avoid dumping raw HTML or raw Fulcra records into chat. - Use the verified annotation name, timestamp, value, and a short interpretation. Avoid exposing unrelated Fulcra data. - Make the next steps concrete: install/open the Context app for mobile logging, inspect data in Context Web, add another annotation, or iterate on the dashboard. - In public or group-chat demos, use synthetic values unless the user explicitly approved sharing real values. ## Idempotent Writer Pattern For automated or backfilled annotation pipelines, use an append-only, ledger-backed writer: 1. Normalize the source event into stable fields: annotation name/id, event timestamp, source, severity/category, value/note, and any real filter dimensions. 2. Generate a dedupe key from stable source facts. Keep this key in local state, metadata, or source bookkeeping; do not place it in the visible note. 3. Check the local ledger before writing. Skip `verified`, retry `pending` or `failed`, and write only unseen keys. 4. Treat ingest HTTP success as provisional. Confirm with `record` output or `recent` readback and mark the ledger `verified` only when `verified_matches >= 1`. 5. For changed source events, avoid duplicate annotations unless the change is materially new. Prefer a follow-up annotation for escalation/resolution over rewriting history. ## Tag Rules - Use definition tags for stable classification of the annotation itself, such as `health`, `agent`, `research`, or `workflow`. - Use record tags for context that applies only to one logged occurrence, such as `new-task`, `manual-test`, `backfill`, or `user-requested`. - Add definition tags with `create --tag <tag>`. Repeat `--tag` for multiple definition tags. - Add record tags with `record --tag <tag>`. Repeat `--tag` for multiple record tags. - `--tag` accepts either a Fulcra tag name or an existing tag UUID. - Fulcra stores tags as UUIDs. The script resolves tag names to UUIDs and creates a missing tag name automatically. - If `record --tag` is omitted, the record uses the definition tags. - If `record --tag` is provided, the explicit record tags are sent for that record. - Use lowercase, short, reusable tags. Prefer `new-task` over a full sentence. - Tags should be filter dimensions, not prose. For places, prefer actual geography such as `town-springfield`, `village-riverside`, `neighborhood-downtown`, or `place-main-campus` over abstract tags such as `scope-town` or `scope-neighborhood`. - Pair place tags with category/source/severity tags when useful, for example `category-traffic`, `source-town-feed`, or `severity-advisory`. - Do not use tags for timestamps, detailed notes, values, people names, dedupe keys, or private context. Use `--recorded-at`, `--note`, `--value`, `--source`, and local metadata/ledger state for those. - Existing definitions can be retagged with `update --id ... --tag ...` after user approval; `--tag` replaces the definition tag set. ## Timestamp Rules - If `--recorded-at` is omitted, the script records the annotation at the current time. - If the user asks for a historical or scheduled-looking time, pass `--recorded-at`. - Use the user's local timezone when resolving relative phrases like "yesterday at 10am". - Include the timezone offset in the timestamp. Do not pass naive local times. - UTC readback is expected. Compare instants, not string equality. Example: on 2026-05-16 in Eastern Time, "yesterday at 10am" means `2026-05-15T10:00:00-04:00`, which readback may show as `2026-05-15T14:00:00+00:00`. ## Verification Rules - `record` returns `recorded_at` and `verified_matches`. - `verified_matches >= 1` means the script found the written record in Fulcra after ingest. - For historical writes, use `recent --hours` with a window large enough to include the target time if a second verification is needed. - When inspecting readback, use only minimal fields needed for confirmation: annotation name/id, `recorded_at`, value if relevant, and note if relevant. ## API Notes Core REST endpoints: - `GET /user/v1alpha1/annotation` lists annotation definitions. - `POST /user/v1alpha1/annotation` creates a definition. - `PUT /user/v1alpha1/annotation/{annotation_id}` updates a definition. - `DELETE /user/v1alpha1/annotation/{annotation_id}` deletes a definition. - `POST /ingest/v1/record` records annotation events. - Readback uses [REDACTED_SECRET] for moment/duration and `/data/v1alpha1/metric/{BooleanAnnotation|NumericAnnotation|ScaleAnnotation}` for metric annotation values. For schema details or upstream gaps, read `references/api-notes.md`.

path: README.md
content: # Fulcra Annotations

Create Fulcra annotation definitions and record annotation events from an agent workflow.

Fulcra gives agents and their humans scoped, secure access to read and write real-world context and shared human/agent memory: attention, events, location, calendar, health, wearables, and other streams. This skill is the write path: agents can create reusable annotation definitions and record moments, booleans, numeric values, and scale ratings after user approval.

## What It Does

- Lists existing Fulcra annotation definitions.
- Creates, updates, and deletes annotation definitions, including definition-level tags.
- Records annotation events, including historical timestamps.
- Supports moment, boolean, numeric, and scale annotations.
- Supports record-level tags for individual logged events.
- Verifies writes by reading the event back after ingest.

## Requirements

- Python 3.11 or newer.
- Authenticated Fulcra account for the target user. No API key is required.
- `uv tool run fulcra-api auth login` completed, or `FULCRA_ACCESS_TOKEN` supplied by a trusted secret manager.

Fulcra accounts can be created through the CLI auth flow and include 5 GB of storage free forever. Users who want biometrics, location, calendar, and other mobile context can install the Context iOS app and sign in with the same account; the app uses the same free storage and is no longer subscription gated. Android is coming soon.

For remote agents, run `uv tool run fulcra-api auth login` on the agent host, keep it polling, and surface only the printed device authorization URL and user code to the intended user in chat through the active trusted user channel. The user can approve from any browser on any device. Never send access tokens or credential files.

If credentials live outside the process home, set `FULCRA_HOME` to the home directory that contains the Fulcra CLI credentials. Set `FULCRA_CLI_COMMAND` only when you need to override the default `uv tool run fulcra-api` command.

## Quick Start

```bash
python3 skills/fulcra-annotations/scripts/fulcra_annotations.py list
```

Create a reusable moment annotation definition:

```bash
python3 skills/fulcra-annotations/scripts/fulcra_annotations.py create \
  --type moment \
  --name "Asked Agent to Do Something New" \
  --description "Logged when the user asks the agent to try a new category of work" \
  --tag agent \
  --tag new-task
```

Record a moment now:

```bash
python3 skills/fulcra-annotations/scripts/fulcra_annotations.py record \
  --name "Asked Agent to Do Something New" \
  --note "User asked for a new annotation workflow" \
  --tag new-task
```

Record a historical moment:

```bash
python3 skills/fulcra-annotations/scripts/fulcra_annotations.py record \
  --name "Asked Agent to Do Something New" \
  --recorded-at "2026-05-15T10:00:00-04:00" \
  --note "Backfilled from user request" \
  --tag backfill
```

The script returns JSON. Treat the write as confirmed only when `verified_matches` is at least `1`.

## First-Run Flow

For a new user, keep the first annotation loop tight:

1. Offer 2-3 concrete tracking ideas instead of asking an open-ended question.
2. Check auth with `uv tool run fulcra-api user-info`; if needed, run `uv tool run fulcra-api auth login` and send only the device URL/code.
3. Create 1-3 definitions, saving the returned `annotation.id`, `source_id`, and type in your working notes.
4. Ask one direct question, record the first value with `record --id ...`, and verify `verified_matches >= 1`.
5. Hand off with the definitions created, the timestamp written, and a concrete next step such as mobile logging, Context Web, another annotation, or a small dashboard.

For public demos or group chats, use synthetic values unless the user explicitly approves sharing real Fulcra records.

Create a numeric count annotation:

```bash
python3 skills/fulcra-annotations/scripts/fulcra_annotations.py create \
  --type numeric \
  --name "Coffee Count" \
  --description "Number of coffees consumed today" \
  --measurement-type count \
  --tag health \
  --tag intake
```

For specialized measurement units, inspect the live schema through the bundled helper before adding script support:

```bash
python3 skills/fulcra-annotations/scripts/fulcra_annotations.py measurement-schema
```

Use `measurement-schema --raw` only for local debugging. Do not paste raw private account output into chat.

Update or delete definitions only after user approval:

```bash
python3 skills/fulcra-annotations/scripts/fulcra_annotations.py update \
  --id "<annotation-id>" \
  --description "Updated description" \
  --tag health \
  --tag workflow

python3 skills/fulcra-annotations/scripts/fulcra_annotations.py delete \
  --id "<annotation-id>"
```

## Idempotent Pipelines

For recurring imports or backfills, keep a local ledger keyed by stable source facts. Write unseen records once, retry pending/failed records, and mark a record verified only after Fulcra readback. Keep dedupe keys out of visible notes; store them in local state, metadata, or source bookkeeping.

## Tags

Tags can apply to either the definition or an individual record.

Use definition tags for stable categories that describe the annotation itself:

```bash
python3 skills/fulcra-annotations/scripts/fulcra_annotations.py create \
  --type moment \
  --name "Medication Taken" \
  --tag health \
  --tag adherence
```

Use record tags for context that only applies to one event:

```bash
python3 skills/fulcra-annotations/scripts/fulcra_annotations.py record \
  --name "Medication Taken" \
  --note "Backfilled after travel" \
  --tag backfill \
  --tag travel
```

Use short, reusable, lowercase tags such as `health`, `agent`, `research`, `new-task`, `manual-test`, or `backfill`. Tags should be filter dimensions. For geography, prefer actual place tags such as `town-springfield`, `village-riverside`, `neighborhood-downtown`, or `place-main-campus`, not abstract scope labels. Do not put timestamps, private notes, dedupe keys, or one-off sentences in tags.

## Safety

- Do not print access tokens, direct capability URLs, or private Fulcra records in chat or logs.
- Use dry-run mode before risky writes.
- Ask before deleting or changing an existing annotation definition.


path: agents/openai.yaml
content: interface:
  display_name: "Fulcra Annotations"
  short_description: "Create, list, and record Fulcra annotations from agents."
  default_prompt: "Create or record a Fulcra annotation."


path: references/api-notes.md
content: # Fulcra Annotation API Notes

Loaded only when schema details matter.

## Definition Endpoints

The public OpenAPI document is served from:

`https://api.fulcradynamics.com/openapi.json`

Documented annotation definition endpoints:

- `GET /user/v1alpha1/annotation`
- `POST /user/v1alpha1/annotation`
- `GET /user/v1alpha1/annotation/{annotation_id}`
- `PUT /user/v1alpha1/annotation/{annotation_id}`
- `DELETE /user/v1alpha1/annotation/{annotation_id}`
- `POST /user/v1alpha1/annotation/{annotation_id}/cancel_deletion`

Supported definition types:

- `moment`
- `boolean`
- `numeric`
- `scale`
- `duration`
- `people`

## Event Ingest

The generic ingest endpoint is:

`POST /ingest/v1/record`

Payload shape:

```json
{
  "specversion": 1,
  "data": "{\"note\":\"optional note\",\"value\":4}",
  "metadata": {
    "data_type": "ScaleAnnotation",
    "recorded_at": "2026-05-14T19:30:00Z",
    "source": [
      "com.example.agent",
      "com.fulcradynamics.annotation.<annotation-id>"
    ],
    "tags": [],
    "content_type": "application/json"
  }
}
```

Observed readback data classes:

- Moment annotations: [REDACTED_SECRET]
- Duration annotations: [REDACTED_SECRET]
- Boolean annotations: [REDACTED_SECRET]
- Numeric annotations: [REDACTED_SECRET]
- Scale annotations: [REDACTED_SECRET]

## Current Gaps

The beta CLI currently does not expose annotation write commands or Magic Link retrieval. Prefer the bundled REST script until upstream CLI support lands.


path: scripts/fulcra_annotations.py
content: #!/usr/bin/env python3
"""Create and record Fulcra annotations.

Generic helper for agents. It intentionally avoids printing secrets and supports
dry-runs for all write commands.
"""

from __future__ import annotations

import argparse
import json
import os
import shlex
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any


API_BASE = os.environ.get("FULCRA_API_BASE", "https://api.fulcradynamics.com").rstrip("/")
DEFAULT_AGENT_SOURCE = os.environ.get("FULCRA_AGENT_SOURCE", "com.fulcra.agent")
PROCESS_HOME = os.environ.get("HOME") or str(Path.home())
DEFAULT_HOME = os.environ.get("FULCRA_HOME") or PROCESS_HOME

TYPE_TO_DATA_TYPE = {
    "moment": "MomentAnnotation",
    "duration": "DurationAnnotation",
    "boolean": "BooleanAnnotation",
    "numeric": "NumericAnnotation",
    "scale": "ScaleAnnotation",
}

TYPE_TO_READ_CLASS = {
    "moment": "event",
    "duration": "event",
    "boolean": "metric",
    "numeric": "metric",
    "scale": "metric",
}


def fail(message: str, code: int = 1) -> None:
    print(json.dumps({"ok": False, "error": message}, indent=2), file=sys.stderr)
    raise SystemExit(code)


def bearer_value() -> str:
    env_token = os.environ.get("FULCRA_ACCESS_TOKEN")
    if env_token:
        return env_token.strip()

    env = os.environ.copy()
    env["HOME"] = DEFAULT_HOME
    if os.environ.get("FULCRA_HOME"):
        env.setdefault("UV_TOOL_DIR", str(Path(PROCESS_HOME) / ".local" / "share" / "uv" / "tools"))
        env.setdefault("UV_CACHE_DIR", str(Path(PROCESS_HOME) / ".cache" / "uv"))

    command = os.environ.get("FULCRA_CLI_COMMAND", "uv tool run fulcra-api")
    candidates = [[*shlex.split(command), "auth", "print-access-token"]]
    for cmd in candidates:
        try:
            [REDACTED_SECRET]
                cmd,
                env=env,
                text=True,
                stderr=subprocess.DEVNULL,
                timeout=45,
            ).strip()
        except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
            continue
        if [REDACTED_SECRET] token
    fail("No Fulcra auth available. Set FULCRA_ACCESS_TOKEN or run Fulcra CLI auth login.")


def request(method: str, path: str, payload: Any | None = None) -> tuple[int, str]:
    data = None
    headers = {"Authorization": f"Bearer {bearer_value()}"}
    if payload is not None:
        data = json.dumps(payload).encode()
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(API_BASE + path, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=30) as response:
            body = response.read()
            return response.status, body.decode() if body else ""
    except urllib.error.HTTPError as exc:
        body = exc.read().decode(errors="replace")
        return exc.code, body


def parse_labels(raw: str | None, low: int = 1, high: int = 5) -> dict[str, str]:
    if not raw:
        return {str(i): str(i) for i in range(low, high + 1)}
    labels: dict[str, str] = {}
    for part in raw.split(","):
        if not part.strip():
            continue
        if "=" not in part:
            fail(f"Invalid --scale-labels item {part!r}; expected N=Label")
        key, value = part.split("=", 1)
        labels[key.strip()] = value.strip()
    return labels


def annotation_source_id(annotation: dict[str, Any]) -> str:
    ann_id = annotation["id"]
    return annotation.get("fulcra_source_id") or f"com.fulcradynamics.annotation.{ann_id}"


def is_uuid(value: str) -> bool:
    try:
        uuid.UUID(value)
    except ValueError:
        return False
    return True


def get_tag_by_name(name: str) -> dict[str, Any] | None:
    path = f"/user/v1alpha1/tag/name/{urllib.parse.quote(name, safe='')}"
    status, body = request("GET", path)
    if status == 200:
        return json.loads(body)
    if status == 404:
        return None
    fail(f"Failed to look up tag {name!r}: HTTP {status}: {body[:500]}")


def create_tag(name: str) -> dict[str, Any]:
    status, body = request("POST", "/user/v1alpha1/tag", {"name": name})
    if status in {200, 201} and body:
        return json.loads(body)
    if status in {200, 201, 303}:
        tag = get_tag_by_name(name)
        if tag:
            return tag
    fail(f"Failed to create tag {name!r}: HTTP {status}: {body[:500]}")


def resolve_tags(raw_tags: list[str] | None, create_missing: bool = True) -> list[str]:
    resolved: list[str] = []
    for raw in raw_tags or []:
        tag = raw.strip()
        if not tag:
            continue
        if is_uuid(tag):
            resolved.append(str(uuid.UUID(tag)))
            continue
        found = get_tag_by_name(tag)
        if not found:
            if not create_missing:
                fail(f"No Fulcra tag named {tag!r}; create it first or allow tag creation.")
            found = create_tag(tag)
        resolved.append(found["id"])
    return resolved


def normalize_annotation(annotation: dict[str, Any]) -> dict[str, Any]:
    return {
        "id": annotation.get("id"),
        "name": annotation.get("name"),
        "type": annotation.get("annotation_type"),
        "source_id": annotation_source_id(annotation),
        "deleted_at": annotation.get("deleted_at"),
        "tags": annotation.get("tags") or [],
    }


def list_annotations(include_deleted: bool = False) -> list[dict[str, Any]]:
    status, body = request("GET", "/user/v1alpha1/annotation")
    if status != 200:
        fail(f"Failed to list annotations: HTTP {status}: {body[:500]}")
    data = json.loads(body)
    rows = [normalize_annotation(item) for item in data]
    if not include_deleted:
        rows = [row for row in rows if not row.get("deleted_at")]
    return rows


def find_annotation(name: str | None = None, annotation_id: str | None = None) -> dict[str, Any]:
    status, body = request("GET", "/user/v1alpha1/annotation")
    if status != 200:
        fail(f"Failed to list annotations: HTTP {status}: {body[:500]}")
    data = json.loads(body)
    active = [row for row in data if not row.get("deleted_at")]
    if annotation_id:
        matches = [row for row in active if row.get("id") == annotation_id]
    else:
        target = (name or "").strip().lower()
        matches = [row for row in active if row.get("name", "").strip().lower() == target]
    if not matches:
        fail("No active annotation matched the requested name/id.")
    if len(matches) > 1:
        fail("Multiple active annotations matched; rerun with --id.")
    return matches[0]


def create_payload(args: argparse.Namespace) -> dict[str, Any]:
    tags = resolve_tags(args.tag)
    base = {
        "annotation_type": args.type,
        "name": args.name,
        "description": args.description or "",
        "tags": tags,
    }
    if args.type == "moment":
        base["measurement_spec"] = None
        base["spec"] = {"default_note": args.default_note} if args.default_note else None
    elif args.type == "boolean":
        default = True if args.default_value is None else str(args.default_value).lower() in {"1", "true", "yes", "y"}
        base["measurement_spec"] = {
            "measurement_type": "boolean",
            "value_type": "boolean",
            "unit": None,
            "boolean": {"value": default},
        }
        base["spec"] = {"default_note": args.default_note} if args.default_note else None
    elif args.type == "numeric":
        value = float(args.default_value) if args.default_value is not None else None
        measurement_type = args.measurement_type or "custom"
        base["measurement_spec"] = {
            "measurement_type": measurement_type,
            "value_type": "real",
            "unit": args.unit,
            measurement_type: {"value": value},
        }
        base["spec"] = {"default_note": args.default_note} if args.default_note else None
    elif args.type == "scale":
        low = int(args.scale_min)
        high = int(args.scale_max)
        default = int(args.default_value) if args.default_value is not None else (low + high) // 2
        base["measurement_spec"] = {
            "measurement_type": "scale",
            "value_type": "integer",
            "unit": None,
            "scale": {"min_allowed": low, "max_allowed": high, "value": default},
        }
        base["spec"] = {
            "default_note": args.default_note,
            "scale": {
                "label_mapping": {
                    "mapping_type": "string",
                    "string": {"mapping": parse_labels(args.scale_labels, low, high)},
                },
                "scale_mapping": None,
            },
        }
    else:
        fail(f"Create support for annotation type {args.type!r} is not implemented.")
    return base


def create_annotation(args: argparse.Namespace) -> dict[str, Any]:
    payload = create_payload(args)
    if args.dry_run:
        return {"ok": True, "dry_run": True, "payload": payload}
    status, body = request("POST", "/user/v1alpha1/annotation", payload)
    if status != 200:
        fail(f"Failed to create annotation: HTTP {status}: {body[:800]}")
    return {"ok": True, "annotation": normalize_annotation(json.loads(body))}


def record_payload(annotation: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
    ann_type = annotation["annotation_type"]
    data_type = TYPE_TO_DATA_TYPE.get(ann_type)
    if not data_type:
        fail(f"Recording {ann_type!r} annotations is not implemented.")
    recorded_at = args.recorded_at or datetime.now(timezone.utc).isoformat()
    data: dict[str, Any] = {}
    if args.note:
        data["note"] = args.note
    if ann_type in {"boolean", "numeric", "scale"}:
        if args.value is None:
            fail(f"--value is required when recording {ann_type} annotations.")
        if ann_type == "boolean":
            data["value"] = str(args.value).lower() in {"1", "true", "yes", "y"}
        elif ann_type == "scale":
            data["value"] = int(args.value)
        else:
            data["value"] = float(args.value)

    source_id = annotation_source_id(annotation)
    tags = resolve_tags(args.tag) if args.tag is not None else (annotation.get("tags") or [])
    return {
        "specversion": 1,
        "data": json.dumps(data),
        "metadata": {
            "data_type": data_type,
            "recorded_at": recorded_at,
            "source": [args.source or DEFAULT_AGENT_SOURCE, source_id],
            "tags": tags,
            "content_type": "application/json",
        },
    }


def verify_record(annotation: dict[str, Any], recorded_at: str) -> int:
    ann_type = annotation["annotation_type"]
    data_type = TYPE_TO_DATA_TYPE[ann_type]
    read_class = TYPE_TO_READ_CLASS[ann_type]
    center = datetime.fromisoformat(recorded_at.replace("Z", "+00:00"))
    query = urllib.parse.urlencode(
        {
            "start_time": (center - timedelta(minutes=5)).isoformat(),
            "end_time": (center + timedelta(minutes=5)).isoformat(),
        }
    )
    status, body = request("GET", f"/data/v1alpha1/{read_class}/{data_type}?{query}")
    if status != 200:
        return 0
    source_id = annotation_source_id(annotation)
    records = json.loads(body)
    return sum(
        1
        for item in records
        if item.get("source_id") == source_id or source_id in item.get("sources", [])
    )


def record_annotation(args: argparse.Namespace) -> dict[str, Any]:
    annotation = find_annotation(name=args.name, annotation_id=args.id)
    payload = record_payload(annotation, args)
    if args.dry_run:
        return {"ok": True, "dry_run": True, "annotation": normalize_annotation(annotation), "payload": payload}
    status, body = request("POST", "/ingest/v1/record", payload)
    if status != 204:
        fail(f"Failed to record annotation: HTTP {status}: {body[:800]}")
    recorded_at = payload["metadata"]["recorded_at"]
    return {
        "ok": True,
        "annotation": normalize_annotation(annotation),
        "recorded_at": recorded_at,
        "verified_matches": verify_record(annotation, recorded_at),
    }


def recent_records(args: argparse.Namespace) -> dict[str, Any]:
    annotation = find_annotation(name=args.name, annotation_id=args.id)
    ann_type = annotation["annotation_type"]
    data_type = TYPE_TO_DATA_TYPE[ann_type]
    read_class = TYPE_TO_READ_CLASS[ann_type]
    end = datetime.now(timezone.utc)
    start = end - timedelta(hours=args.hours)
    query = urllib.parse.urlencode({"start_time": start.isoformat(), "end_time": end.isoformat()})
    status, body = request("GET", f"/data/v1alpha1/{read_class}/{data_type}?{query}")
    if status != 200:
        fail(f"Failed to read records: HTTP {status}: {body[:500]}")
    source_id = annotation_source_id(annotation)
    records = [
        item
        for item in json.loads(body)
        if item.get("source_id") == source_id or source_id in item.get("sources", [])
    ]
    return {
        "ok": True,
        "annotation": normalize_annotation(annotation),
        "count": len(records),
        "records": records[-args.limit :],
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Create and record Fulcra annotations")
    sub = parser.add_subparsers(dest="command", required=True)

    list_cmd = sub.add_parser("list")
    list_cmd.add_argument("--include-deleted", action="store_true")

    create = sub.add_parser("create")
    create.add_argument("--type", choices=["moment", "boolean", "numeric", "scale"], required=True)
    create.add_argument("--name", required=True)
    create.add_argument("--description", default="")
    create.add_argument("--tag", action="append")
    create.add_argument("--default-note")
    create.add_argument("--default-value")
    create.add_argument("--unit")
    create.add_argument("--measurement-type", choices=["custom", "count", "mass", "distance", "percent", "temperature", "volume"])
    create.add_argument("--scale-min", default=1)
    create.add_argument("--scale-max", default=5)
    create.add_argument("--scale-labels")
    create.add_argument("--dry-run", action="store_true")

    record = sub.add_parser("record")
    record.add_argument("--name")
    record.add_argument("--id")
    record.add_argument("--value")
    record.add_argument("--note")
    record.add_argument("--recorded-at")
    record.add_argument("--source")
    record.add_argument("--tag", action="append")
    record.add_argument("--dry-run", action="store_true")

    recent = sub.add_parser("recent")
    recent.add_argument("--name")
    recent.add_argument("--id")
    recent.add_argument("--hours", type=float, default=24)
    recent.add_argument("--limit", type=int, default=10)

    args = parser.parse_args()
    if args.command == "list":
        result = {"ok": True, "annotations": list_annotations(args.include_deleted)}
    elif args.command == "create":
        result = create_annotation(args)
    elif args.command == "record":
        if not args.name and not args.id:
            fail("record requires --name or --id")
        result = record_annotation(args)
    elif args.command == "recent":
        if not args.name and not args.id:
            fail("recent requires --name or --id")
        result = recent_records(args)
    else:
        fail("unknown command")

    print(json.dumps(result, indent=2, sort_keys=True))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)