main · last commit
13 days ago ·
7g0stsfu
ah-2lh Claim-time Yonote artifact materialization into .task/artifacts/ + prompt manifest
Past Stand
bd reopen ah-2lh
| Created by | Eugene Blikh |
| Owner | bigbes@gmail.com |
| Created | 2026-07-18T15:04:08Z |
| Started | 2026-07-18T15:06:57Z |
| Updated | 2026-07-18T15:59:08Z |
| Closed | 2026-07-18T15:59:08Z |
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).
# 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.
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).
ah-gxa
— internal/yonote: light API client (doc resolve/export, create, comments; bot-token auth)
blocks
closed
ah-25e
— Yonote publish lane: .task/publish.json → bot-authored docs at finalize
blocks
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
| 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 |
| 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 |
| 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 |
No comments.
Close reason