Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion docs/reference/protocols/periodic-report-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,34 @@ records whether generation is allowed, the normalized profile digest, and the
portable/enhanced/durable extension mode. It performs no source read, schedule
mutation, provider lookup, rendering, archive write, or message delivery.

### Audience policy and announcement plan

A custom profile may include `periodic_report_audience_policy_v0`. The policy
contains symbolic recipients, an eligible visibility set (primary by default),
and no provider identity. Each recipient must own one or more normalized domain
tokens or declare one or more `periodic_report_audience_routing_rule_v0`
objects. A rule may select `source_ids`, `section_ids`, `content_kinds`,
`tags_any`, or `domains_any`; every selector declared by that rule must match
one normalized item. An item may carry `domains` in addition to its existing
tags and content kind.

`periodic_report_announcement_plan_v0` is a deterministic, provider-neutral
projection over the exact normalized document and audience policy. It records
the selected symbolic recipient ids, matching item references, typed match
reasons, and policy/document digests. An owned-domain intersection or explicit
routing-rule match is required. Supporting items are ignored unless the policy
explicitly makes that visibility eligible. An unrelated recipient is omitted,
and title or summary text never infers relevance.

The Lark delivery adapter accepts the document and policy together. Preview
returns the plan with no identity resolution or external effect. Execute asks
an injected Lark identity adapter to render only the selected symbolic ids,
prepends those `<at>` elements, then uses the existing send and exact-readback
contract. A selected recipient without an identity renderer, a mismatched
artifact/document digest, or mention markup authored into report content,
title, or footer fails closed before send. This keeps relevance in the core
contract and provider identity on the extension side.

`periodic_report_project_progress_projection_v0` is the built-in,
domain-neutral source input. It groups typed project facts into progress,
capability evolution, risks, next actions, and supporting evidence, with no
Expand Down Expand Up @@ -234,11 +262,32 @@ own idempotency and readback receipt; it is not renderer authority.
The bundled Lark extension includes an opt-in `miaoda_html` delivery sink for
`html_artifact_v0`. It validates the single HTML, compressed archive, and
uncompressed payload limits before any external effect. A successful receipt
requires exact readback of the profile-owned app id, published URL, and
requires exact readback of the request-selected app id, published URL, and
published state, and also records the observed access scope and login
requirement. The project or host still owns app selection, authentication,
audience policy, and the execute decision.

`loopx periodic-report publish-miaoda` is the concrete CLI for that sink. Its
`periodic_report_miaoda_delivery_request_v0` must carry a complete normalized
profile, the exact `periodic_report_generation_bundle_v0`, and a
`periodic_report_delivery_intent_v0` with `kind=hosted`,
`sink_kind=miaoda_html`, a profile-bound sink id, an operator-selected existing
app id, and a stable idempotency key. The selected profile binding must use
`report.miaoda_html.publish@v0`, `loopx-lark`, and
`periodic_report_sink_v0`; an omitted, disabled, or differently typed sink is
rejected before provider execution.

Without `--execute`, the command performs no provider call and returns
`periodic_report_miaoda_delivery_result_v0` with
`status=pending_execution`, `intent_satisfied=false`, and a pending delivery
receipt. With `--execute`, it uses authenticated `lark-cli` publication and
requires exact readback of the same app id, online URL, and published state.
Only that verified result sets `intent_satisfied=true`. The generation bundle
remains usable in either case, but local HTML never satisfies the hosted
delivery intent. The command does not accept credentials, create apps, select
an audience, mutate access scope, send chat notifications, or apply schedule
policy.

The normalized document's optional `editorial` input is split by ownership.
The project profile owns bounded `kicker`, `period_label`, `language`, and zero
to four ordered public-safe highlights. The document builder owns `summary` and
Expand Down
6 changes: 3 additions & 3 deletions examples/lark-extension-activation-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def run_cli(
assert active["extension_activation"] == {
"schema_version": "loopx_extension_activation_v0",
"extension_id": "loopx-lark",
"provider_version": "1.4.0",
"provider_version": "1.5.0",
"revision": installed["revision"],
"enabled": True,
"doctor_verified": True,
Expand Down Expand Up @@ -264,11 +264,11 @@ def run_cli(
assert enabled["doctor"]["verified"] is True, enabled

bundled_manifest = ROOT / "loopx" / "extensions" / "lark" / "extension.toml"
upgraded_manifest = temp / "loopx-lark-v1.5.toml"
upgraded_manifest = temp / "loopx-lark-v1.6.toml"
upgraded_manifest.write_text(
bundled_manifest.read_text(encoding="utf-8").replace(
'version = "1.4.0"',
'version = "1.5.0"',
'version = "1.6.0"',
1,
),
encoding="utf-8",
Expand Down
83 changes: 81 additions & 2 deletions loopx/capabilities/periodic_report/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,31 @@ cadence, collect repository and discussion signals, render a team card, archive
the artifact, and deliver it to a configured channel. None of those choices
becomes an invariant of the shared core or the in-session preset.

## Audience relevance and Lark announcements

A custom profile may declare a `periodic_report_audience_policy_v0`. Recipients
use stable symbolic ids and must declare at least one owned domain or typed
routing rule. Report items may carry normalized `domains`; routing rules may
also select explicit source ids, section ids, content kinds, tags, or domains.
Every declared selector in one rule must match the same normalized item.

`build_periodic_report_announcement_plan` compiles those facts into a
provider-neutral `periodic_report_announcement_plan_v0`. A recipient is
mentioned only when at least one eligible report item intersects an owned
domain or matches an explicit rule. Primary items are eligible by default;
supporting evidence is excluded unless the profile opts it in. The default for
an unrelated recipient is omission. Titles, summaries, authored mention text,
provider identities, and external lookups never participate in selection.

The Lark delivery adapter may consume the exact normalized document and policy
alongside the rendered artifact. Preview returns the announcement plan without
resolving an identity or sending a message. On execute, the extension resolves
only the selected symbolic ids through an injected provider adapter and places
those verified `<at>` elements before the report. A match without a renderer,
a mismatched document/artifact digest, or provider mention markup embedded in
the artifact, title, or footer fails before send. The core therefore owns
relevance while the Lark extension owns provider identity and wire rendering.

This is a built-in capability, not an extension: callers need the trigger,
idempotency, retry, and receipt contract even when no provider is installed.
Optional or independently versioned collectors, renderers, archive stores, and
Expand Down Expand Up @@ -238,7 +263,8 @@ reusable, project-neutral report.

The bundled `loopx-lark` extension provides an opt-in `miaoda_html` delivery
sink for the self-contained `html_artifact_v0` output. The sink publishes the
already-rendered artifact to a profile-owned Miaoda HTML app; it does not
already-rendered artifact to a project-owned existing Miaoda HTML app selected
by the delivery request; it does not
rebuild the document or choose an audience. Before any external effect it
checks the single HTML, compressed archive, and uncompressed payload limits.
After publication it requires exact readback of the same app id, published
Expand All @@ -260,7 +286,7 @@ external writes remain disabled by default:
},
"extension": {
"extension_id": "loopx-lark",
"extension_version": "1.4.0",
"extension_version": "1.5.0",
"protocol": "periodic_report_sink_v0"
}
}
Expand All @@ -272,6 +298,59 @@ the injected publish or readback effects. Repeated publication should reuse the
same app id and delivery idempotency key instead of creating a new app for each
report.

The public CLI makes that boundary executable. A
`periodic_report_miaoda_delivery_request_v0` contains the full normalized
profile, its `periodic_report_generation_bundle_v0`, and one typed
`periodic_report_delivery_intent_v0`:

```json
{
"schema_version": "periodic_report_miaoda_delivery_request_v0",
"profile": { "schema_version": "periodic_report_profile_v0" },
"generation_bundle": {
"schema_version": "periodic_report_generation_bundle_v0"
},
"delivery_intent": {
"schema_version": "periodic_report_delivery_intent_v0",
"kind": "hosted",
"sink_id": "miaoda_html_delivery",
"sink_kind": "miaoda_html",
"app_id": "app_example123",
"idempotency_key": "weekly-report-2026-29"
}
}
```

The abbreviated profile and generation objects above must be replaced by their
complete normalized receipts. Preview the exact request first:

```bash
loopx periodic-report publish-miaoda \
--request-json periodic-report-miaoda-request.json \
--format json
```

Preview performs size, profile, binding, extension, and artifact checks but
returns `status=pending_execution` and `intent_satisfied=false`. Local HTML
generation is therefore useful output, not proof that a hosted-report request
was delivered. Publish only after the operator authorizes the external write:

```bash
loopx periodic-report publish-miaoda \
--request-json periodic-report-miaoda-request.json \
--execute \
--format json
```

The command resolves the installed, enabled, doctor-verified `loopx-lark`
revision and its `lark.miaoda_html.publish` permission. Authentication remains
inside `lark-cli`; the request accepts no token or credential. The provider
publishes a temporary `index.html`, reads the exact app back, and sets
`intent_satisfied=true` only when the same app id, online URL, and published
state agree. It does not create an app, change the app's audience, or change its
access scope. Disable or roll back the bundled extension to remove the provider
without affecting already-generated local artifacts.

## Default editorial contract

The reusable renderer deliberately separates audience content from operational
Expand Down
6 changes: 6 additions & 0 deletions loopx/capabilities/periodic_report/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
build_periodic_report_archive_bundle,
verify_periodic_report_archive_receipts,
)
from .audience import (
build_periodic_report_announcement_plan,
normalize_periodic_report_audience_policy,
)
from .bindings import (
build_periodic_report_delivery_receipt,
build_periodic_report_extension_readiness,
Expand Down Expand Up @@ -51,6 +55,7 @@
"PROJECT_PROGRESS_PROJECTION_SCHEMA",
"WEEKLY_PROGRESS_PRESET_ID",
"build_periodic_report_activation",
"build_periodic_report_announcement_plan",
"build_periodic_report_document",
"build_periodic_report_delivery_receipt",
"build_periodic_report_editorial",
Expand All @@ -63,6 +68,7 @@
"build_periodic_report_source_result",
"build_periodic_report_trigger_decision",
"normalize_periodic_report_profile",
"normalize_periodic_report_audience_policy",
"normalize_periodic_report_sink_bindings",
"normalize_periodic_report_trigger_policy",
"project_progress_periodic_report_source_adapter",
Expand Down
8 changes: 8 additions & 0 deletions loopx/capabilities/periodic_report/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ def _normalize_item(raw: object, *, label: str) -> dict[str, Any]:
)
if tags:
normalized["tags"] = tags
domains = sorted(
{
_token(value, f"{label}.domains[]")
for value in _sequence(item.get("domains", []), f"{label}.domains")
}
)
if domains:
normalized["domains"] = domains
raw_tag_labels = _mapping(item.get("tag_labels", {}), f"{label}.tag_labels")
tag_labels: dict[str, str] = {}
for raw_tag, raw_label in raw_tag_labels.items():
Expand Down
Loading