~bigbes/agents-dev · parade

main · last commit 13 days ago · 7g0stsfu

← Back to the parade

ah-2lh Claim-time Yonote artifact materialization into .task/artifacts/ + prompt manifest Past Stand

status: closed P2 feature @Eugene Blikh
bd reopen ah-2lh
Created byEugene Blikh
Ownerbigbes@gmail.com
Created2026-07-18T15:04:08Z
Started2026-07-18T15:06:57Z
Updated2026-07-18T15:59:08Z
Closed2026-07-18T15:59:08Z
Description
Why: the operator authors specs/big documents in Yonote and wants task cards to reference them by URL; the claimed agent must see the CURRENT doc content without the repo ever carrying it. What: optional yonote config block (base_url, token via ${YONOTE_TOKEN} from /etc/agent-hub/env, claim_budget); at claim, scan the frontmatter-stripped description body for https://<yonote-host>/doc/<seg> URLs (works on both raw-markdown and Vikunja TipTap-HTML descriptions), resolve via documents.info, export markdown (with .text fallback), write .task/artifacts/<NN>-<slug>.md via StartSpec.Artifacts in prepareTaskDir, append a '## Reference documents' manifest to the prompt, add an artifacts count to the claim comment + an event. Dead references NEVER fail the claim; every attempt re-exports fresh. wave 2.5 — implement after feat/wave2-archive-links merges. Depends on ah-gxa (client).
Design
# Claim-time Yonote artifact materialization

Operator authors specs/big docs in Yonote; a task card references them by URL; at claim the
daemon exports each referenced doc to `.task/artifacts/<NN>-<slug>.md` in the worktree and lists
them in the prompt. `.task/` is git-excluded (excludeTaskDir) and archived to tar.gz at Done, so
artifacts never touch the repo.

## Config (internal/config)

New OPTIONAL top-level block, feature fully inert when absent (telegram/ntfy presence pattern):

    yonote:
      base_url: "https://bigbes.yonote.ru"   # required when present; validateHTTPURL
      token: "${YONOTE_TOKEN}"               # required when present; BOT token (see client bead runbook); ${VAR} from /etc/agent-hub/env via existing expandEnv
      claim_budget: "90s"                    # optional; TOTAL wall clock for all exports in one claim; default 90s; positive (parseDuration)

`Yonote{BaseURL, Token string; ClaimBudget time.Duration}` + `present()` (trimmed BaseURL or Token
non-empty) + validation (both required when present; URL check; strict KnownFields comes free from
rawConfig). Trim trailing "/" off BaseURL at resolve (AgentsView precedent).
Wiring (internal/deps or cmd wiring, wherever mem0/vikunja clients are built): when present →
`yonote.New(BaseURL, Token)`; call AuthInfo once at startup: log identity, WARN if !IsBot; a
startup AuthInfo FAILURE logs an error and continues — Yonote outage must never block board work.

## Reference convention (DECISION)

Every substring of the card description matching `https?://<host-of-base_url>/doc/<seg>` is a
reference; `<seg>` = last path segment `[A-Za-z0-9._~-]+` (query/fragment excluded by charset).
Extraction runs over the frontmatter-STRIPPED body (`res.Body` from spec.Resolve — the same text
the prompt template receives). Order of first occurrence; dedup by RESOLVED document id (two URL
forms of one doc collapse); cap `maxArtifactRefs = 10` (const, not config) — refs past the cap get
manifest lines "skipped: over per-task artifact cap".

Why bare-URL matching, not an `artifact:` prefix line:
(a) descriptions reach the daemon in TWO shapes — raw markdown on daemon-created child cards, and
    TipTap HTML (`<p>…<a href="URL">text</a></p>`) on operator-edited cards; internal/vikunja
    passes Description verbatim (board.go), nothing normalizes HTML — a line-anchored convention
    breaks under HTML rewrapping, host+path substring matching survives both;
(b) zero ceremony: paste a link into the card and it just works;
(c) false positives are benign — a linked Yonote doc IS relevant context by definition.
`/collection/...` and `/share/...` URLs deliberately NOT matched in v1. Resolution: pass `<seg>`
verbatim to documents.info (accepts uuid | urlId | slug-urlId — live-verified) → canonical uuid,
title, archivedAt.

## Claim flow (internal/reconcile claim(), after renderPrompt succeeds, before runner.Start)

    refs := yonote.ParseDocRefs(cfg.Yonote.BaseURL, res.Body)     // nil client or 0 refs → unchanged behavior
    ctx2 := context.WithTimeout(ctx, cfg.Yonote.ClaimBudget)      // one budget for ALL refs; reconcile loop is single-goroutine (PublishTimeout precedent)
    serially per ref:
      doc, err := DocumentInfo(ctx2, ref)          // err (404 deleted, 401, timeout…) → unavailable(reason), continue
      md, err  := ExportMarkdown(ctx2, doc.ID)     // MUST use doc.ID (uuid) — urlId 400s
      if err (incl. whiteboard 500) → unavailable(reason)
      if md == ""       → md = doc.Text            // API-created never-edited docs keep markdown in .text; database docs are "" both ways
      if md still ""    → unavailable("document exported empty")
      marker "(archived)" when doc.ArchivedAt != nil — still materialized (archived docs stay readable)
      Artifact{FileName: fmt.Sprintf("%02d-%s.md", n, slug(doc.Title)), Content: md,
               Title: doc.Title, SourceURL: cfg.Yonote.BaseURL + doc.URL}
    budget exhausted → remaining refs unavailable("artifact budget exhausted")

slug(): lowercase; non-[a-z0-9] runs → "-"; trim "-"; cap 60 chars; empty → doc.URLID. The NN-
ordinal prefix makes collisions impossible. Cyrillic titles will slug to "" often → URLID fallback
matters.

FAILURE SEMANTICS (spec-style): NOTHING in materialization ever fails or bounces the claim. A dead
/ archived / empty / oversized-budget reference degrades to a manifest warning line + a claim-
comment count + a warn log. Rationale: the doc is context, not a precondition; the operator sees
the warning immediately (prompt + card comment) and can fix the link and re-run. No triage bounce
(spec is not malformed), no failed state (nothing ran).

## Prompt manifest

Reconciler APPENDS to the rendered prompt (role templates untouched; keeps SPEC §12 rendering
contract intact):

    ## Reference documents (.task/artifacts/)
    Exported from Yonote at claim time; read them before starting; treat as read-only input.
    1. "<Title>" — .task/artifacts/01-<slug>.md (source: <abs url>)
    2. "<Title>" (archived) — .task/artifacts/02-<slug>.md (source: <abs url>)
    3. "<Title or ref>" — UNAVAILABLE (<reason>) (source: <abs url>)

## Ports + runner

- ports.StartSpec gains `Artifacts []Artifact`; `type Artifact struct{ FileName, Content, Title, SourceURL string }`.
- runner prepareTaskDir: ALWAYS `os.RemoveAll(.task/artifacts)` first (attempt-scoped channel —
  same rationale as clearing summary/tasks/question: attempt N-1 exports must not leak into
  attempt N). Then when len(Artifacts)>0: MkdirAll + writeFileAtomic each. Guard: reject FileName
  containing "/" or ".." (defense in depth; daemon generates them).
- RE-CLAIM SEMANTICS (DECISION): every attempt RE-EXPORTS fresh content. Freshness wins over
  snapshot stability because the whole point is "the current spec", specs get edited between
  attempts, and each finished attempt's snapshot is already preserved by the Done-archive tar.gz.
  A doc that died between attempts becomes an UNAVAILABLE manifest line (stale file removed by the
  RemoveAll), so the agent never reads outdated content silently.

## Observability

- Claim comment (existing "attempt N started · …" line) gains, only when refs were found:
  `· artifacts: N exported[, M unavailable]`.
- appendEvent "artifacts" {"exported": N, "unavailable": M} (deduped like existing events).

## Interface seam

reconcile depends on a narrow local interface (fake-friendly, matches existing port style):

    type yonoteExporter interface {
        DocumentInfo(ctx context.Context, id string) (*yonote.Document, error)
        ExportMarkdown(ctx context.Context, uuid string) (string, error)
    }

nil = feature off. *yonote.Client satisfies it.

## Tests

- config: block absent (inert) / present-partial (errors join) / bad URL / ${VAR} unset problem /
  budget default + non-positive rejection (mirror telegram/ntfy config tests).
- reconcile claim with fake exporter: 2-ref happy path (manifest text, StartSpec.Artifacts,
  comment suffix, event payload); 404 ref → UNAVAILABLE + claim proceeds; markdown "" → Text
  fallback; both empty → unavailable; budget timeout → remaining skipped; dedup two URL forms of
  one doc; TipTap-HTML description with <a href=...>; cap at 10; archived marker; nil exporter →
  byte-identical prompt to today.
- runner: artifacts written under .task/artifacts/; stale dir removed when Artifacts empty;
  traversal FileName rejected.

wave 2.5 — implement after feat/wave2-archive-links merges. Depends on the internal/yonote client
bead.
Notes
Code-recon evidence: (1) descriptions reach the daemon VERBATIM — internal/vikunja/board.go:110 copies wire Description straight into ports.BoardTask; there is NO html→markdown normalization on read (markdownToHTML in internal/vikunja/markdown.go is write-side, comments only) — so operator-edited cards arrive as TipTap HTML while daemon-created child cards (reconcile.go childDescription) are raw markdown: the URL scanner must handle both, which is why the convention is host+path substring matching, not an 'artifact:' line. (2) Hook point: internal/reconcile/reconcile.go claim(), after renderPrompt success (~line 362-404) and before runner.Start; prompt is plain string concatenation — role templates (SPEC §12) stay untouched. (3) File writing: internal/runner/runner.go prepareTaskDir (~591-633) already clears attempt-scoped channels (summary/tasks/question) — artifacts dir clearing joins that list; writeFileAtomic + excludeTaskDir + Done-archive tar.gz already cover durability/git-exclusion/archival (SPEC §9). (4) API evidence for the fallback chain and failure modes lives in ah-gxa notes: /markdown is uuid-only, returns '' for API-created-never-edited drafts and database docs, 500s for whiteboards, and .text is plaintext after editor edits — hence markdown → .text → UNAVAILABLE. (5) claim runs on the single reconcile goroutine — claim_budget bounds total materialization wall-clock (PublishTimeout precedent, config.go). (6) documents.info of an ARCHIVED doc was NOT live-verifiable (workspace has no archived docs); Outline heritage says it returns the doc with archivedAt set — treated as exportable + '(archived)' marker; implementer verifies by archiving a scratch doc in the UI once. Nested docs: no doc with childrenCount>0 exists in the workspace; /markdown has no children param (single-doc export is spec-consistent) — v1 semantics = SINGLE doc per reference, children never walked (documents.list {parentDocumentId} exists if a future version wants a tree).

Depends on

  • ah-gxa — internal/yonote: light API client (doc resolve/export, create, comments; bot-token auth) blocks closed

Depended on by

  • ah-25e — Yonote publish lane: .task/publish.json → bot-authored docs at finalize blocks

Unblocks — everything waiting on this, transitively

  • ah-25e — Yonote publish lane: .task/publish.json → bot-authored docs at finalize blocks closed
  • ah-ptu — Yonote Q&A bot loop: poll doc comments, answer via board tasks (post-wave-2.5) blocks closed
  • ah-eje — Q&A loop rollout: live-verify events.list ordering + comments.resolve, wire qa role/config on agent-1 blocks closed

No comments.

Close reason

Merged to master f9a7b4b (3 commits 6c9e259/a25b692/f9a7b4b): yonote client + claim-time artifact materialization + publish lane. Live smoke at rollout per ah-25e notes.
  • Eugene Blikh created the issue · 2026-07-18T18:04:07Z
  • Eugene Blikh added dependency on ah-gxa · 2026-07-18T18:04:55Z
  • Eugene Blikh ah-25e now depends on this · 2026-07-18T18:04:56Z
  • Eugene Blikh claimed · 2026-07-18T18:06:56Z
  • Eugene Blikh closed the issue · 2026-07-18T18:59:07Z
    Merged to master f9a7b4b (3 commits 6c9e259/a25b692/f9a7b4b): yonote client + claim-time artifact materialization + publish lane. Live smoke at rollout per ah-25e notes.
  • Eugene Blikh added label milestone:yonote · 2026-07-20T03:08:53Z
  • Eugene Blikh removed label milestone:yonote · 2026-07-20T03:10:30Z
Stored rows — what this pane was built from, as read
issues 1 row
id ah-2lh
content_hash 6dae3f01f06b06dcd0357975dff946bac3138df4a0a351273573d592977ecb6e
title Claim-time Yonote artifact materialization into .task/artifacts/ + prompt manifest
description Why: the operator authors specs/big documents in Yonote and wants task cards to reference them by URL; the claimed agent must see the CURRENT doc content without the repo ever carrying it. What: optional yonote config block (base_url, token via ${YONOTE_TOKEN} from /etc/agent-hub/env, claim_budget); at claim, scan the frontmatter-stripped description body for https://<yonote-host>/doc/<seg> URLs (works on both raw-markdown and Vikunja TipTap-HTML descriptions), resolve via documents.info, export markdown (with .text fallback), write .task/artifacts/<NN>-<slug>.md via StartSpec.Artifacts in prepareTaskDir, append a '## Reference documents' manifest to the prompt, add an artifacts count to the claim comment + an event. Dead references NEVER fail the claim; every attempt re-exports fresh. wave 2.5 — implement after feat/wave2-archive-links merges. Depends on ah-gxa (client).
design # Claim-time Yonote artifact materialization Operator authors specs/big docs in Yonote; a task card references them by URL; at claim the daemon exports each referenced doc to `.task/artifacts/<NN>-<slug>.md` in the worktree and lists them in the prompt. `.task/` is git-excluded (excludeTaskDir) and archived to tar.gz at Done, so artifacts never touch the repo. ## Config (internal/config) New OPTIONAL top-level block, feature fully inert when absent (telegram/ntfy presence pattern): yonote: base_url: "https://bigbes.yonote.ru" # required when present; validateHTTPURL token: "${YONOTE_TOKEN}" # required when present; BOT token (see client bead runbook); ${VAR} from /etc/agent-hub/env via existing expandEnv claim_budget: "90s" # optional; TOTAL wall clock for all exports in one claim; default 90s; positive (parseDuration) `Yonote{BaseURL, Token string; ClaimBudget time.Duration}` + `present()` (trimmed BaseURL or Token non-empty) + validation (both required when present; URL check; strict KnownFields comes free from rawConfig). Trim trailing "/" off BaseURL at resolve (AgentsView precedent). Wiring (internal/deps or cmd wiring, wherever mem0/vikunja clients are built): when present → `yonote.New(BaseURL, Token)`; call AuthInfo once at startup: log identity, WARN if !IsBot; a startup AuthInfo FAILURE logs an error and continues — Yonote outage must never block board work. ## Reference convention (DECISION) Every substring of the card description matching `https?://<host-of-base_url>/doc/<seg>` is a reference; `<seg>` = last path segment `[A-Za-z0-9._~-]+` (query/fragment excluded by charset). Extraction runs over the frontmatter-STRIPPED body (`res.Body` from spec.Resolve — the same text the prompt template receives). Order of first occurrence; dedup by RESOLVED document id (two URL forms of one doc collapse); cap `maxArtifactRefs = 10` (const, not config) — refs past the cap get manifest lines "skipped: over per-task artifact cap". Why bare-URL matching, not an `artifact:` prefix line: (a) descriptions reach the daemon in TWO shapes — raw markdown on daemon-created child cards, and TipTap HTML (`<p>…<a href="URL">text</a></p>`) on operator-edited cards; internal/vikunja passes Description verbatim (board.go), nothing normalizes HTML — a line-anchored convention breaks under HTML rewrapping, host+path substring matching survives both; (b) zero ceremony: paste a link into the card and it just works; (c) false positives are benign — a linked Yonote doc IS relevant context by definition. `/collection/...` and `/share/...` URLs deliberately NOT matched in v1. Resolution: pass `<seg>` verbatim to documents.info (accepts uuid | urlId | slug-urlId — live-verified) → canonical uuid, title, archivedAt. ## Claim flow (internal/reconcile claim(), after renderPrompt succeeds, before runner.Start) refs := yonote.ParseDocRefs(cfg.Yonote.BaseURL, res.Body) // nil client or 0 refs → unchanged behavior ctx2 := context.WithTimeout(ctx, cfg.Yonote.ClaimBudget) // one budget for ALL refs; reconcile loop is single-goroutine (PublishTimeout precedent) serially per ref: doc, err := DocumentInfo(ctx2, ref) // err (404 deleted, 401, timeout…) → unavailable(reason), continue md, err := ExportMarkdown(ctx2, doc.ID) // MUST use doc.ID (uuid) — urlId 400s if err (incl. whiteboard 500) → unavailable(reason) if md == "" → md = doc.Text // API-created never-edited docs keep markdown in .text; database docs are "" both ways if md still "" → unavailable("document exported empty") marker "(archived)" when doc.ArchivedAt != nil — still materialized (archived docs stay readable) Artifact{FileName: fmt.Sprintf("%02d-%s.md", n, slug(doc.Title)), Content: md, Title: doc.Title, SourceURL: cfg.Yonote.BaseURL + doc.URL} budget exhausted → remaining refs unavailable("artifact budget exhausted") slug(): lowercase; non-[a-z0-9] runs → "-"; trim "-"; cap 60 chars; empty → doc.URLID. The NN- ordinal prefix makes collisions impossible. Cyrillic titles will slug to "" often → URLID fallback matters. FAILURE SEMANTICS (spec-style): NOTHING in materialization ever fails or bounces the claim. A dead / archived / empty / oversized-budget reference degrades to a manifest warning line + a claim- comment count + a warn log. Rationale: the doc is context, not a precondition; the operator sees the warning immediately (prompt + card comment) and can fix the link and re-run. No triage bounce (spec is not malformed), no failed state (nothing ran). ## Prompt manifest Reconciler APPENDS to the rendered prompt (role templates untouched; keeps SPEC §12 rendering contract intact): ## Reference documents (.task/artifacts/) Exported from Yonote at claim time; read them before starting; treat as read-only input. 1. "<Title>" — .task/artifacts/01-<slug>.md (source: <abs url>) 2. "<Title>" (archived) — .task/artifacts/02-<slug>.md (source: <abs url>) 3. "<Title or ref>" — UNAVAILABLE (<reason>) (source: <abs url>) ## Ports + runner - ports.StartSpec gains `Artifacts []Artifact`; `type Artifact struct{ FileName, Content, Title, SourceURL string }`. - runner prepareTaskDir: ALWAYS `os.RemoveAll(.task/artifacts)` first (attempt-scoped channel — same rationale as clearing summary/tasks/question: attempt N-1 exports must not leak into attempt N). Then when len(Artifacts)>0: MkdirAll + writeFileAtomic each. Guard: reject FileName containing "/" or ".." (defense in depth; daemon generates them). - RE-CLAIM SEMANTICS (DECISION): every attempt RE-EXPORTS fresh content. Freshness wins over snapshot stability because the whole point is "the current spec", specs get edited between attempts, and each finished attempt's snapshot is already preserved by the Done-archive tar.gz. A doc that died between attempts becomes an UNAVAILABLE manifest line (stale file removed by the RemoveAll), so the agent never reads outdated content silently. ## Observability - Claim comment (existing "attempt N started · …" line) gains, only when refs were found: `· artifacts: N exported[, M unavailable]`. - appendEvent "artifacts" {"exported": N, "unavailable": M} (deduped like existing events). ## Interface seam reconcile depends on a narrow local interface (fake-friendly, matches existing port style): type yonoteExporter interface { DocumentInfo(ctx context.Context, id string) (*yonote.Document, error) ExportMarkdown(ctx context.Context, uuid string) (string, error) } nil = feature off. *yonote.Client satisfies it. ## Tests - config: block absent (inert) / present-partial (errors join) / bad URL / ${VAR} unset problem / budget default + non-positive rejection (mirror telegram/ntfy config tests). - reconcile claim with fake exporter: 2-ref happy path (manifest text, StartSpec.Artifacts, comment suffix, event payload); 404 ref → UNAVAILABLE + claim proceeds; markdown "" → Text fallback; both empty → unavailable; budget timeout → remaining skipped; dedup two URL forms of one doc; TipTap-HTML description with <a href=...>; cap at 10; archived marker; nil exporter → byte-identical prompt to today. - runner: artifacts written under .task/artifacts/; stale dir removed when Artifacts empty; traversal FileName rejected. wave 2.5 — implement after feat/wave2-archive-links merges. Depends on the internal/yonote client bead.
acceptance_criteria
notes Code-recon evidence: (1) descriptions reach the daemon VERBATIM — internal/vikunja/board.go:110 copies wire Description straight into ports.BoardTask; there is NO html→markdown normalization on read (markdownToHTML in internal/vikunja/markdown.go is write-side, comments only) — so operator-edited cards arrive as TipTap HTML while daemon-created child cards (reconcile.go childDescription) are raw markdown: the URL scanner must handle both, which is why the convention is host+path substring matching, not an 'artifact:' line. (2) Hook point: internal/reconcile/reconcile.go claim(), after renderPrompt success (~line 362-404) and before runner.Start; prompt is plain string concatenation — role templates (SPEC §12) stay untouched. (3) File writing: internal/runner/runner.go prepareTaskDir (~591-633) already clears attempt-scoped channels (summary/tasks/question) — artifacts dir clearing joins that list; writeFileAtomic + excludeTaskDir + Done-archive tar.gz already cover durability/git-exclusion/archival (SPEC §9). (4) API evidence for the fallback chain and failure modes lives in ah-gxa notes: /markdown is uuid-only, returns '' for API-created-never-edited drafts and database docs, 500s for whiteboards, and .text is plaintext after editor edits — hence markdown → .text → UNAVAILABLE. (5) claim runs on the single reconcile goroutine — claim_budget bounds total materialization wall-clock (PublishTimeout precedent, config.go). (6) documents.info of an ARCHIVED doc was NOT live-verifiable (workspace has no archived docs); Outline heritage says it returns the doc with archivedAt set — treated as exportable + '(archived)' marker; implementer verifies by archiving a scratch doc in the UI once. Nested docs: no doc with childrenCount>0 exists in the workspace; /markdown has no children param (single-doc export is spec-consistent) — v1 semantics = SINGLE doc per reference, children never walked (documents.list {parentDocumentId} exists if a future version wants a tree).
status closed
priority 2
issue_type feature
assignee Eugene Blikh
estimated_minutes NULL
created_at 2026-07-18T15:04:08Z
created_by Eugene Blikh
owner bigbes@gmail.com
updated_at 2026-07-18T15:59:08Z
closed_at 2026-07-18T15:59:08Z
closed_by_session
external_ref NULL
spec_id
compaction_level 0
compacted_at NULL
compacted_at_commit NULL
original_size NULL
sender
ephemeral 0
wisp_type
pinned 0
is_template 0
mol_type
work_type
source_system
metadata �{}
source_repo
close_reason Merged to master f9a7b4b (3 commits 6c9e259/a25b692/f9a7b4b): yonote client + claim-time artifact materialization + publish lane. Live smoke at rollout per ah-25e notes.
event_kind
actor
target
payload
await_type
await_id
timeout_ns 0
waiters
hook_bead
role_bead
agent_state
last_activity NULL
role_type
rig
due_at NULL
defer_until NULL
no_history 0
started_at 2026-07-18T15:06:57Z
is_blocked 0
dependencies 2 rows
id 56546f03-0823-5534-84f8-a1d919c5ce0b
issue_id ah-25e
type blocks
created_at 2026-07-18T18:04:56Z
created_by Eugene Blikh
metadata �{}
thread_id
depends_on_issue_id ah-2lh
depends_on_wisp_id NULL
depends_on_external NULL
id c216a252-8df4-59d4-b191-6d2ab71ef4f5
issue_id ah-2lh
type blocks
created_at 2026-07-18T18:04:55Z
created_by Eugene Blikh
metadata �{}
thread_id
depends_on_issue_id ah-gxa
depends_on_wisp_id NULL
depends_on_external NULL
events 5 rows
id 019f75c1-90fd-73d8-81c2-6a722e009315
issue_id ah-2lh
event_type created
actor Eugene Blikh
old_value
new_value
comment NULL
created_at 2026-07-18T18:04:07Z
id 019f75c4-250b-78b2-9074-3ad41aae73fa
issue_id ah-2lh
event_type claimed
actor Eugene Blikh
old_value {"id":"ah-2lh","title":"Claim-time Yonote artifact materialization into .task/artifacts/ + prompt manifest","description":"Why: the operator authors specs/big documents in Yonote and wants task cards to reference them by URL; the claimed agent must see the CURRENT doc content without the repo ever carrying it. What: optional yonote config block (base_url, token via ${YONOTE_TOKEN} from /etc/agent-hub/env, claim_budget); at claim, scan the frontmatter-stripped description body for https://\u003cyonote-host\u003e/doc/\u003cseg\u003e URLs (works on both raw-markdown and Vikunja TipTap-HTML descriptions), resolve via documents.info, export markdown (with .text fallback), write .task/artifacts/\u003cNN\u003e-\u003cslug\u003e.md via StartSpec.Artifacts in prepareTaskDir, append a '## Reference documents' manifest to the prompt, add an artifacts count to the claim comment + an event. Dead references NEVER fail the claim; every attempt re-exports fresh. wave 2.5 — implement after feat/wave2-archive-links merges. Depends on ah-gxa (client).","design":"# Claim-time Yonote artifact materialization\n\nOperator authors specs/big docs in Yonote; a task card references them by URL; at claim the\ndaemon exports each referenced doc to `.task/artifacts/\u003cNN\u003e-\u003cslug\u003e.md` in the worktree and lists\nthem in the prompt. `.task/` is git-excluded (excludeTaskDir) and archived to tar.gz at Done, so\nartifacts never touch the repo.\n\n## Config (internal/config)\n\nNew OPTIONAL top-level block, feature fully inert when absent (telegram/ntfy presence pattern):\n\n yonote:\n base_url: \"https://bigbes.yonote.ru\" # required when present; validateHTTPURL\n token: \"${YONOTE_TOKEN}\" # required when present; BOT token (see client bead runbook); ${VAR} from /etc/agent-hub/env via existing expandEnv\n claim_budget: \"90s\" # optional; TOTAL wall clock for all exports in one claim; default 90s; positive (parseDuration)\n\n`Yonote{BaseURL, Token string; ClaimBudget time.Duration}` + `present()` (trimmed BaseURL or Token\nnon-empty) + validation (both required when present; URL check; strict KnownFields comes free from\nrawConfig). Trim trailing \"/\" off BaseURL at resolve (AgentsView precedent).\nWiring (internal/deps or cmd wiring, wherever mem0/vikunja clients are built): when present →\n`yonote.New(BaseURL, Token)`; call AuthInfo once at startup: log identity, WARN if !IsBot; a\nstartup AuthInfo FAILURE logs an error and continues — Yonote outage must never block board work.\n\n## Reference convention (DECISION)\n\nEvery substring of the card description matching `https?://\u003chost-of-base_url\u003e/doc/\u003cseg\u003e` is a\nreference; `\u003cseg\u003e` = last path segment `[A-Za-z0-9._~-]+` (query/fragment excluded by charset).\nExtraction runs over the frontmatter-STRIPPED body (`res.Body` from spec.Resolve — the same text\nthe prompt template receives). Order of first occurrence; dedup by RESOLVED document id (two URL\nforms of one doc collapse); cap `maxArtifactRefs = 10` (const, not config) — refs past the cap get\nmanifest lines \"skipped: over per-task artifact cap\".\n\nWhy bare-URL matching, not an `artifact:` prefix line:\n(a) descriptions reach the daemon in TWO shapes — raw markdown on daemon-created child cards, and\n TipTap HTML (`\u003cp\u003e…\u003ca href=\"URL\"\u003etext\u003c/a\u003e\u003c/p\u003e`) on operator-edited cards; internal/vikunja\n passes Description verbatim (board.go), nothing normalizes HTML — a line-anchored convention\n breaks under HTML rewrapping, host+path substring matching survives both;\n(b) zero ceremony: paste a link into the card and it just works;\n(c) false positives are benign — a linked Yonote doc IS relevant context by definition.\n`/collection/...` and `/share/...` URLs deliberately NOT matched in v1. Resolution: pass `\u003cseg\u003e`\nverbatim to documents.info (accepts uuid | urlId | slug-urlId — live-verified) → canonical uuid,\ntitle, archivedAt.\n\n## Claim flow (internal/reconcile claim(), after renderPrompt succeeds, before runner.Start)\n\n refs := yonote.ParseDocRefs(cfg.Yonote.BaseURL, res.Body) // nil client or 0 refs → unchanged behavior\n ctx2 := context.WithTimeout(ctx, cfg.Yonote.ClaimBudget) // one budget for ALL refs; reconcile loop is single-goroutine (PublishTimeout precedent)\n serially per ref:\n doc, err := DocumentInfo(ctx2, ref) // err (404 deleted, 401, timeout…) → unavailable(reason), continue\n md, err := ExportMarkdown(ctx2, doc.ID) // MUST use doc.ID (uuid) — urlId 400s\n if err (incl. whiteboard 500) → unavailable(reason)\n if md == \"\" → md = doc.Text // API-created never-edited docs keep markdown in .text; database docs are \"\" both ways\n if md still \"\" → unavailable(\"document exported empty\")\n marker \"(archived)\" when doc.ArchivedAt != nil — still materialized (archived docs stay readable)\n Artifact{FileName: fmt.Sprintf(\"%02d-%s.md\", n, slug(doc.Title)), Content: md,\n Title: doc.Title, SourceURL: cfg.Yonote.BaseURL + doc.URL}\n budget exhausted → remaining refs unavailable(\"artifact budget exhausted\")\n\nslug(): lowercase; non-[a-z0-9] runs → \"-\"; trim \"-\"; cap 60 chars; empty → doc.URLID. The NN-\nordinal prefix makes collisions impossible. Cyrillic titles will slug to \"\" often → URLID fallback\nmatters.\n\nFAILURE SEMANTICS (spec-style): NOTHING in materialization ever fails or bounces the claim. A dead\n/ archived / empty / oversized-budget reference degrades to a manifest warning line + a claim-\ncomment count + a warn log. Rationale: the doc is context, not a precondition; the operator sees\nthe warning immediately (prompt + card comment) and can fix the link and re-run. No triage bounce\n(spec is not malformed), no failed state (nothing ran).\n\n## Prompt manifest\n\nReconciler APPENDS to the rendered prompt (role templates untouched; keeps SPEC §12 rendering\ncontract intact):\n\n ## Reference documents (.task/artifacts/)\n Exported from Yonote at claim time; read them before starting; treat as read-only input.\n 1. \"\u003cTitle\u003e\" — .task/artifacts/01-\u003cslug\u003e.md (source: \u003cabs url\u003e)\n 2. \"\u003cTitle\u003e\" (archived) — .task/artifacts/02-\u003cslug\u003e.md (source: \u003cabs url\u003e)\n 3. \"\u003cTitle or ref\u003e\" — UNAVAILABLE (\u003creason\u003e) (source: \u003cabs url\u003e)\n\n## Ports + runner\n\n- ports.StartSpec gains `Artifacts []Artifact`; `type Artifact struct{ FileName, Content, Title, SourceURL string }`.\n- runner prepareTaskDir: ALWAYS `os.RemoveAll(.task/artifacts)` first (attempt-scoped channel —\n same rationale as clearing summary/tasks/question: attempt N-1 exports must not leak into\n attempt N). Then when len(Artifacts)\u003e0: MkdirAll + writeFileAtomic each. Guard: reject FileName\n containing \"/\" or \"..\" (defense in depth; daemon generates them).\n- RE-CLAIM SEMANTICS (DECISION): every attempt RE-EXPORTS fresh content. Freshness wins over\n snapshot stability because the whole point is \"the current spec\", specs get edited between\n attempts, and each finished attempt's snapshot is already preserved by the Done-archive tar.gz.\n A doc that died between attempts becomes an UNAVAILABLE manifest line (stale file removed by the\n RemoveAll), so the agent never reads outdated content silently.\n\n## Observability\n\n- Claim comment (existing \"attempt N started · …\" line) gains, only when refs were found:\n `· artifacts: N exported[, M unavailable]`.\n- appendEvent \"artifacts\" {\"exported\": N, \"unavailable\": M} (deduped like existing events).\n\n## Interface seam\n\nreconcile depends on a narrow local interface (fake-friendly, matches existing port style):\n\n type yonoteExporter interface {\n DocumentInfo(ctx context.Context, id string) (*yonote.Document, error)\n ExportMarkdown(ctx context.Context, uuid string) (string, error)\n }\n\nnil = feature off. *yonote.Client satisfies it.\n\n## Tests\n\n- config: block absent (inert) / present-partial (errors join) / bad URL / ${VAR} unset problem /\n budget default + non-positive rejection (mirror telegram/ntfy config tests).\n- reconcile claim with fake exporter: 2-ref happy path (manifest text, StartSpec.Artifacts,\n comment suffix, event payload); 404 ref → UNAVAILABLE + claim proceeds; markdown \"\" → Text\n fallback; both empty → unavailable; budget timeout → remaining skipped; dedup two URL forms of\n one doc; TipTap-HTML description with \u003ca href=...\u003e; cap at 10; archived marker; nil exporter →\n byte-identical prompt to today.\n- runner: artifacts written under .task/artifacts/; stale dir removed when Artifacts empty;\n traversal FileName rejected.\n\nwave 2.5 — implement after feat/wave2-archive-links merges. Depends on the internal/yonote client\nbead.\n","notes":"Code-recon evidence: (1) descriptions reach the daemon VERBATIM — internal/vikunja/board.go:110 copies wire Description straight into ports.BoardTask; there is NO html→markdown normalization on read (markdownToHTML in internal/vikunja/markdown.go is write-side, comments only) — so operator-edited cards arrive as TipTap HTML while daemon-created child cards (reconcile.go childDescription) are raw markdown: the URL scanner must handle both, which is why the convention is host+path substring matching, not an 'artifact:' line. (2) Hook point: internal/reconcile/reconcile.go claim(), after renderPrompt success (~line 362-404) and before runner.Start; prompt is plain string concatenation — role templates (SPEC §12) stay untouched. (3) File writing: internal/runner/runner.go prepareTaskDir (~591-633) already clears attempt-scoped channels (summary/tasks/question) — artifacts dir clearing joins that list; writeFileAtomic + excludeTaskDir + Done-archive tar.gz already cover durability/git-exclusion/archival (SPEC §9). (4) API evidence for the fallback chain and failure modes lives in ah-gxa notes: /markdown is uuid-only, returns '' for API-created-never-edited drafts and database docs, 500s for whiteboards, and .text is plaintext after editor edits — hence markdown → .text → UNAVAILABLE. (5) claim runs on the single reconcile goroutine — claim_budget bounds total materialization wall-clock (PublishTimeout precedent, config.go). (6) documents.info of an ARCHIVED doc was NOT live-verifiable (workspace has no archived docs); Outline heritage says it returns the doc with archivedAt set — treated as exportable + '(archived)' marker; implementer verifies by archiving a scratch doc in the UI once. Nested docs: no doc with childrenCount\u003e0 exists in the workspace; /markdown has no children param (single-doc export is spec-consistent) — v1 semantics = SINGLE doc per reference, children never walked (documents.list {parentDocumentId} exists if a future version wants a tree).","status":"open","priority":2,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-07-18T15:04:08Z","created_by":"Eugene Blikh","updated_at":"2026-07-18T15:04:08Z"}
new_value {"assignee":"Eugene Blikh","status":"in_progress"}
comment NULL
created_at 2026-07-18T18:06:56Z
id 019f75f3-eaef-73c8-897f-5b9c8cbe15f6
issue_id ah-2lh
event_type closed
actor Eugene Blikh
old_value
new_value Merged to master f9a7b4b (3 commits 6c9e259/a25b692/f9a7b4b): yonote client + claim-time artifact materialization + publish lane. Live smoke at rollout per ah-25e notes.
comment NULL
created_at 2026-07-18T18:59:07Z
id 019f7cda-aa8a-7abe-8e67-9b24d44d8868
issue_id ah-2lh
event_type label_added
actor Eugene Blikh
old_value NULL
new_value NULL
comment Added label: milestone:yonote
created_at 2026-07-20T03:08:53Z
id 019f7cdc-26f9-763d-bb57-278fa73ad9bf
issue_id ah-2lh
event_type label_removed
actor Eugene Blikh
old_value NULL
new_value NULL
comment Removed label: milestone:yonote
created_at 2026-07-20T03:10:30Z