diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5260f7060..347d1c24c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,11 +126,13 @@ jobs: - name: npm run lint run: npm run lint - - name: npm run build - run: npm run build + # The build prerenders against the generated publication data, so it is + # driven through `make site` — the same command used locally, which starts + # the build-time content server and stops it afterwards. + - name: make site + working-directory: . + run: make site - - name: production syndication contract - run: npm run test:syndication # Production deploy. `needs` lists every check job above, so a red check skips # the deploy; the `if` restricts it to a push landing on main (a pull_request diff --git a/.golangci.yml b/.golangci.yml index b0743a2f7..c8410740a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -120,13 +120,17 @@ linters: paths: - vendor rules: - # Test files allow higher complexity and flexible helpers + # Test files allow higher complexity and flexible helpers. gosec is + # excluded because its file-inclusion and directory-permission checks + # guard untrusted input and deployed artifacts; a test reading a path it + # just wrote under t.TempDir() is neither. - path: _test\.go$ linters: - gocyclo - gocognit - errcheck - unparam + - gosec # Generated code skipped - path: internal/db/ diff --git a/Makefile b/Makefile index 9acc0af97..deb6b1f64 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,25 @@ # migrations/ before each suite. CI runs the SAME `make test-integration` # command so local and CI behaviour cannot drift. -.PHONY: test test-integration verify +.PHONY: test test-integration verify site publish + +# Publication data: renders content/ into the frontend's public directory. +# Fast and dependency-free, so it runs before every site build rather than +# being cached. +publish: + go run ./cmd/publish -content content -out frontend/public + +# The public site, end to end: snapshots in, static files out. +# +# Prerendering fetches the publication data over HTTP, so the generated files +# are served on 127.0.0.1 for the length of the build. The server is killed +# whether the build succeeds or fails; a leaked one would silently serve stale +# data to the next build. +site: publish + cd frontend && \ + node tools/serve-content.mjs public 8099 & echo $$! > .site-server.pid; \ + trap 'kill $$(cat .site-server.pid) 2>/dev/null; rm -f .site-server.pid' EXIT; \ + cd frontend && npm run build # Unit lane: race detector, NO integration build tag. Mirrors the CI `go` job. test: diff --git a/cmd/publish/main.go b/cmd/publish/main.go new file mode 100644 index 000000000..eba7482d9 --- /dev/null +++ b/cmd/publish/main.go @@ -0,0 +1,114 @@ +// Command publish renders the snapshots under content/ into the static data the +// public site is built from. It is the whole publication pipeline; there is no +// server. +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/Koopa0/koopa/internal/publication" +) + +// Exit codes, for callers that are not human. Bad content and a bad invocation +// are separated because only the first is worth opening content/ for. +const ( + exitOK = 0 + exitFailure = 1 + exitUsage = 2 +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +type options struct { + contentDir string + outDir string + site publication.Site +} + +func run(args []string, stdout, stderr io.Writer) int { + opts, code, err := parse(args, stderr) + if err != nil { + fmt.Fprintf(stderr, "publish: %v\n", err) + return code + } + if code != exitOK { + return code + } + + articles, err := publication.Load(os.DirFS(opts.contentDir)) + if err != nil { + fmt.Fprintf(stderr, "publish: %v\n", err) + return exitFailure + } + + files, err := publication.Build(opts.site, articles) + if err != nil { + fmt.Fprintf(stderr, "publish: %v\n", err) + return exitFailure + } + + if err := write(opts.outDir, files); err != nil { + fmt.Fprintf(stderr, "publish: %v\n", err) + return exitFailure + } + + fmt.Fprintf(stdout, "published %d article(s) to %s\n", len(articles), opts.outDir) + return exitOK +} + +// parse returns exitOK with a nil error when -h was handled. +func parse(args []string, stderr io.Writer) (options, int, error) { + var opts options + + fs := flag.NewFlagSet("publish", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.StringVar(&opts.contentDir, "content", "content", "directory of publication snapshots") + fs.StringVar(&opts.outDir, "out", "frontend/public", "directory to render the site data into") + fs.StringVar(&opts.site.BaseURL, "base-url", "https://koopa0.dev", "absolute site origin, without a trailing slash") + fs.StringVar(&opts.site.Title, "title", "koopa0.dev", "site title, used in the feed") + fs.StringVar(&opts.site.Description, "description", "Notes on Go, systems, and the craft of building them.", "feed channel description") + fs.StringVar(&opts.site.Author, "author", "Koopa", "feed managing editor") + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return opts, exitOK, nil + } + return opts, exitUsage, err + } + if fs.NArg() > 0 { + return opts, exitUsage, fmt.Errorf("unexpected argument %q", fs.Arg(0)) + } + + opts.site.BaseURL = strings.TrimRight(opts.site.BaseURL, "/") + if opts.site.BaseURL == "" { + return opts, exitUsage, errors.New("-base-url is required") + } + if _, err := os.Stat(opts.contentDir); err != nil { + return opts, exitUsage, fmt.Errorf("-content: %w", err) + } + + return opts, exitOK, nil +} + +// write is deliberately not atomic: the output is regenerated from scratch, so +// a partial write is discarded rather than served. +func write(root string, files []publication.File) error { + for _, f := range files { + dest := filepath.Join(root, filepath.FromSlash(f.Path)) + if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil { + return fmt.Errorf("creating %s: %w", filepath.Dir(dest), err) + } + if err := os.WriteFile(dest, f.Bytes, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", dest, err) + } + } + return nil +} diff --git a/cmd/publish/main_test.go b/cmd/publish/main_test.go new file mode 100644 index 000000000..49bcf3d91 --- /dev/null +++ b/cmd/publish/main_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +const snapshot = `--- +title: Escape analysis +topics: [go, performance] +published_at: 2026-07-28 +source_path: Writing/articles/go-escape-analysis.md +source_sha: 3f2a91c0d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9 +--- +Variables live on the stack until they do not. +` + +// corpus writes a content directory holding the given files and returns its +// path along with a separate output directory. +func corpus(t *testing.T, files map[string]string) (contentDir, outDir string) { + t.Helper() + root := t.TempDir() + contentDir = filepath.Join(root, "content") + outDir = filepath.Join(root, "public") + + if err := os.MkdirAll(contentDir, 0o755); err != nil { + t.Fatalf("creating content dir: %v", err) + } + for name, body := range files { + if err := os.WriteFile(filepath.Join(contentDir, name), []byte(body), 0o644); err != nil { + t.Fatalf("writing %s: %v", name, err) + } + } + return contentDir, outDir +} + +// TestPublishRendersTheSite is the command's happy path: a snapshot in, a +// complete site out, exit 0. +func TestPublishRendersTheSite(t *testing.T) { + contentDir, outDir := corpus(t, map[string]string{"go-escape-analysis.md": snapshot}) + + var stdout, stderr bytes.Buffer + code := run([]string{"-content", contentDir, "-out", outDir}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, stderr.String()) + } + if !strings.Contains(stdout.String(), "published 1 article") { + t.Errorf("stdout = %q, want it to report one article", stdout.String()) + } + + for _, name := range []string{ + "content/index.json", + "content/go-escape-analysis.json", + "sitemap.xml", + "feed.xml", + } { + if _, err := os.Stat(filepath.Join(outDir, filepath.FromSlash(name))); err != nil { + t.Errorf("expected %s: %v", name, err) + } + } +} + +// TestPublishFailsOnInvalidSnapshot protects the build: content that violates +// the contract must stop the pipeline, not publish a partial site. +func TestPublishFailsOnInvalidSnapshot(t *testing.T) { + contentDir, outDir := corpus(t, map[string]string{ + "good.md": snapshot, + "bad.md": "---\ntitle: No provenance\npublished_at: 2026-07-28\n---\nBody.\n", + }) + + var stdout, stderr bytes.Buffer + code := run([]string{"-content", contentDir, "-out", outDir}, &stdout, &stderr) + + if code != exitFailure { + t.Fatalf("exit = %d, want %d", code, exitFailure) + } + if !strings.Contains(stderr.String(), "source_path") { + t.Errorf("stderr = %q, want it to name the offending field", stderr.String()) + } + if _, err := os.Stat(filepath.Join(outDir, "feed.xml")); err == nil { + t.Error("a feed was written despite an invalid snapshot") + } +} + +// TestPublishRejectsBadInvocation separates "your content is wrong" from "your +// command is wrong", because only the first is worth opening the content for. +func TestPublishRejectsBadInvocation(t *testing.T) { + contentDir, outDir := corpus(t, map[string]string{"go-escape-analysis.md": snapshot}) + + tests := map[string][]string{ + "unknown flag": {"-nonsense"}, + "stray argument": {"-content", contentDir, "-out", outDir, "extra"}, + "missing content dir": {"-content", filepath.Join(contentDir, "absent"), "-out", outDir}, + "empty base URL": {"-content", contentDir, "-out", outDir, "-base-url", ""}, + "base URL only a slash": {"-content", contentDir, "-out", outDir, "-base-url", "/"}, + } + + for name, args := range tests { + t.Run(name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr); code != exitUsage { + t.Errorf("exit = %d, want %d (stderr: %s)", code, exitUsage, stderr.String()) + } + }) + } +} + +// TestPublishEmptyCorpus is the starting state: no snapshots committed yet, and +// the build still succeeds and produces a valid empty site. +func TestPublishEmptyCorpus(t *testing.T) { + contentDir, outDir := corpus(t, nil) + + var stdout, stderr bytes.Buffer + code := run([]string{"-content", contentDir, "-out", outDir}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, stderr.String()) + } + if !strings.Contains(stdout.String(), "published 0 article") { + t.Errorf("stdout = %q, want it to report an empty corpus", stdout.String()) + } + if _, err := os.Stat(filepath.Join(outDir, "feed.xml")); err != nil { + t.Errorf("an empty corpus must still produce a feed: %v", err) + } +} + +// TestPublishIsReproducible protects the build's determinism: the same content +// must produce the same bytes, or every build shows as a change in review. +func TestPublishIsReproducible(t *testing.T) { + contentDir, outDir := corpus(t, map[string]string{ + "go-escape-analysis.md": snapshot, + "second.md": strings.Replace(snapshot, "Escape analysis", "Second", 1), + }) + + read := func() map[string]string { + t.Helper() + var stdout, stderr bytes.Buffer + if code := run([]string{"-content", contentDir, "-out", outDir}, &stdout, &stderr); code != exitOK { + t.Fatalf("exit = %d (stderr: %s)", code, stderr.String()) + } + out := map[string]string{} + for _, name := range []string{"content/index.json", "sitemap.xml", "feed.xml"} { + body, err := os.ReadFile(filepath.Join(outDir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("reading %s: %v", name, err) + } + out[name] = string(body) + } + return out + } + + first := read() + second := read() + for name, want := range first { + if second[name] != want { + t.Errorf("%s differs between builds of identical content", name) + } + } +} diff --git a/content/.gitkeep b/content/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docs/claude-mcp-feature-requests.md b/docs/claude-mcp-feature-requests.md new file mode 100644 index 000000000..d2b1e9666 --- /dev/null +++ b/docs/claude-mcp-feature-requests.md @@ -0,0 +1,130 @@ +# Feature requests — reducing owner-in-the-loop friction for Claude's MCP write authority + +Not committed as a repo doc by default — written to `docs/` to match the existing +style of `hermes-proposals-push-spec.md` / `backend-semantic-contract.md`, but left +for Koopa to decide whether it's worth keeping as a permanent record or just reading +once and discarding. + +## Who is asking, and why + +This is Claude (Fable 5), operating as an MCP client of this server under `as: claude` +— one of the three actors in this system alongside the human owner (Koopa) and the +local hermes agent (`as: hermes`). What follows is a list of concrete places where the +current MCP surface makes Claude depend on Koopa clicking something in the admin UI +for work that is otherwise already fully specified and low-risk, plus the evidence for +each. + +The owner has been explicit about the direction: he does not want to keep manually +re-authorizing the same shape of decision over and over, but he also wants any +expanded authority to still leave a durable, inspectable record inside this system — +not routed around it (no out-of-band chat-based approval flows; approval happens in +koopa0.dev or it didn't happen). Everything below is written with that constraint in +mind: nothing here proposes bypassing this system as the record of truth, only +reducing how many times a human has to individually click through it for +well-specified, reversible work. + +None of this proposes touching the two things that stay owner-only regardless: +**merge/publish/activate of anything externally visible**, and **the +identity/authorization model** (`created_by` provenance, the inert-by-default posture +of `propose_*`). The asks are scoped inside those boundaries. + +## Evidence this is a real, not hypothetical, problem + +On 2026-07-02, a backlog of 25 hermes-authored proposals (`proposals/inbox/*.md` in +the hermes repo) got pushed into this system's inbox via `capture_inbox` after a +week-long delivery bug was fixed. All 22 that made it through landed as +`state=inbox`, `created_by=hermes`. As the Claude session that pushed them and that +had already independently verified each one's cited evidence (a proposal-enrichment +pass), there was no path to move a single one forward: + +- `resolve_todo` only accepts `done | archived | dismissed`, and only for todos where + `created_by` matches the caller's own resolved identity — so even after fact-checking + all 22, none of them (e.g. the ones with disconfirmed evidence) could be dismissed. +- There is no MCP tool for the `inbox → todo` "clarify" transition at all; per the + tool's own doc comment it's admin-UI-only. +- `list_todos` has no filter parameters, so there's no cheap way to ask "what changed + since I last looked" — the whole inbox gets re-read every time to figure out what + the owner has already triaged. +- `capture_inbox`'s only structural association is `project` (fuzzy-matched against + Project entities). At the time of writing `project_progress` returns `projects: []` + (only goals/areas populated) — possibly a bug the owner is independently + investigating — so none of the 22 items carry any structured link to the PARA goal + they were proposed against, even though every source proposal has one in its own + metadata. + +The practical effect: every one of these 22 items requires the owner to open the admin +UI and individually process it, for work where the actual judgment call (is this +worth doing) is the only step that needs him — the mechanical bookkeeping around it +doesn't. + +## Requested features + +### F1 — `clarify_todo` tool (inbox → todo) + +Advance a todo from `state=inbox` to `state=todo`, setting project/energy/due — the +same fields the admin UI's clarify dialog sets. Scope: restrict to todos where +`created_by` matches the caller's own identity (same pattern as `resolve_todo`), so +this doesn't let any caller clarify the owner's own inbox items, only its own. This +turns "hermes proposed something well-formed, Claude verified it" into a two-step +round trip instead of requiring the owner as a mandatory third hop for every item. + +### F2 — cross-creator resolve for the verifying agent + +Today `resolve_todo` requires `created_by == caller`. In practice hermes creates +proposals and Claude is the one that verifies and (once approved) executes them — so +Claude is frequently the right party to close out a hermes-created todo once the +underlying work is done, but structurally cannot. Requested: allow `as: claude` to +resolve todos with `created_by: hermes` specifically (not a general relaxation — +`claude` resolving `koopa`'s own items should stay blocked). This mirrors the existing +owner-approves-then-Claude-executes handoff (documented in the hermes repo's +Task-Handoff protocol) but currently that handoff only works for the *content* of the +work, not for closing the loop on the todo record itself. + +### F3 — `list_todos` filters + +Add optional `state`, `created_by`, and `since` (timestamp) parameters. Today it's an +unfiltered full list. This is what would answer "did the owner already triage any of +these while I wasn't looking" cheaply, instead of re-deriving it by re-reading +everything — the actual gap that produces the "Claude and the owner's local tracking +system silently drift out of sync" problem described above. + +### F4 — fix (or confirm intentional) `project_progress` returning zero projects + +Flagging, not fully specifying — the owner is independently checking whether this is +a bug. If it is: this blocks any proposal from ever carrying a real `project` +association (see evidence section above), which is a prerequisite for +`capture_inbox`'s existing `project` field to be useful at all for +hermes/Claude-originated items. + +### F5 — proposal categories + bulk triage + +Add an optional `category` (freeform or a small fixed set) to `capture_inbox` / +`propose_*`, and a corresponding bulk-transition action (admin UI and/or MCP) that +acts on all inbox items matching a category. Rationale: several proposals today are +structurally identical in shape and risk profile (e.g. "kick off a translation +pipeline for course X, reusing the already-proven pipeline for course Y") — the owner +currently has to individually approve each one even when he'd be comfortable +pre-authorizing the whole category in one decision. This doesn't remove his approval +step; it lets one approval cover N items he considers equivalent, and the category tag +stays in the record either way. + +### F6 — lightweight, non-actionable status push + +A tool for hermes/Claude to post a short status note (what was done, evidence/links) +that surfaces in the next `brief` call, without creating a `todo` that needs triage. +Rationale: today the only way to tell the owner "I finished X" is a chat message he +has to be present for, or a `capture_inbox` item that (incorrectly) implies it needs a +decision. A separate, explicitly non-actionable channel would let him catch up on +completed work asynchronously (e.g. in his morning brief) instead of needing to ask. + +## Explicitly not requesting + +- **Not** asking for any bypass of this system as the record of truth (e.g. approving + via Telegram or chat instead of here) — the owner was explicit that approval has to + happen and be recorded in koopa0.dev itself. +- **Not** asking for publish/activation authority on anything externally visible. + Separately flagging one open question for the owner's own consideration, not + requesting it be granted here: whether R0/R1 `propose_content` items (single-draft + edits, already-verified source material) could eventually have a narrower "Claude + marks review-complete" step distinct from "owner publishes" — raised for discussion, + not part of this request. diff --git a/docs/design-briefing-2026-07-25.md b/docs/design-briefing-2026-07-25.md new file mode 100644 index 000000000..63cba8c93 --- /dev/null +++ b/docs/design-briefing-2026-07-25.md @@ -0,0 +1,449 @@ +# koopa 重新設計 — 設計簡報(自足文件) + +你正在被委託做一份獨立設計。另有一位同等級的設計者拿到**完全相同的這份文件**、在**不知道你的內容**的情況下同時作業。之後 owner、你、另一位設計者、以及做完稽核的那個 assistant 會坐下來收斂。 + +這份文件包含你需要的全部脈絡。你沒有其他上下文,也不需要其他上下文。文中所有指令與路徑都可以自己去複核——請複核你打算靠著站的那幾條。 + +--- + +## 0. 標記法(先讀這段,否則你會攻擊錯東西) + +文中每一條事實都帶標記,三種: + +| 標記 | 意義 | 你該怎麼對待 | +|---|---|---| +| **CONSTRAINT** | owner 的授權決定(locks / locked outcomes / 2026-07-25 裁決) | **不可攻擊。**只能問「這條在我的設計裡怎麼被滿足」。你可以說某條約束讓某個方案不可行——那是有用的;你不可以把它當成待證偽的假設。 | +| **VERIFIED** | 有指令、路徑、行號或 runtime 實驗支撐 | 可以自己複核。若你複核後發現不成立,**明講**,那是這次最有價值的產出之一。 | +| **INFERRED** | 從 VERIFIED 事實推出來的判斷 | **請主動攻擊。**沒有一條 INFERRED 值得你讓步。 | +| **UNKNOWN** | 沒人量過,其中兩條在拆除後永久不可得 | 見 §7。你的設計必須說明它在哪一種答案下會立刻錯。 | + +第 4 節只有事實,沒有「所以」。所有「所以」在第 5 節,並被明確標成「一種讀法」。這個切法是刻意的:如果第 4 節替你下了結論,找兩位獨立設計者就沒有意義了。 + +--- + +## 1. 系統與人 + +**koopa0.dev**:單一擁有者的私人規劃 + 公開發佈系統。Go 1.26 + PostgreSQL + pgx/v5 + sqlc + net/http(stdlib-first、無 web framework、無 testify)、一個對 ~3 個 AI agent 曝露 14 個工具的 MCP server、一個 Angular 22 SSR 前端。repo 在 `/Users/koopa/koopa0.dev`。 + +**Koopa 本人**:獨立接案的 Go 工程師,5 年以上,ArdanLabs 訓練,自學出身,從第一原理建構,不信任魔法與框架。 + +**他的治理原則(這是尺,不是偏好)**: + +> **convergent over expansionary。**拿不準該給多少時,答案幾乎永遠是「更少」。傾向移除而非添加。每一個 dependency 必須自證必要。**「你不需要這個」對他是禮物,不是失敗。** + +他要對抗性的 pushback,不要附和。他明確拒絕「我們已經蓋了所以繼續蓋」這種論證。 + +**他已經擁有而且真的在運轉的基礎設施**:15 個 hermes cron job(全部 enabled、last_status=ok、合計約 1,300 次完成),帶 lease(`fire_claim`)、attempt log、report sink(`~/.hermes/cron/output/{job}/{ts}.md`)、以及一個 Telegram 投遞通道;Claude Code sessions(含 `/loop`、cron agent、subagent、hook);一個 Obsidian vault;yomihon(本地 Go+templ 的 vault 閱讀器);git 與 GitHub。 + +--- + +## 2. CONSTRAINT — 綁定約束 + +### 2.1 Owner locks(2026-07-15/16) + +1. 單一擁有者的**執行 + 發佈**系統。 +2. Obsidian / Yomihon 是知識側;兩邊不得互相依賴;任一離線,另一邊的核心仍成立。 +3. **Diary 只活在 Obsidian**——永不進入 Koopa、agent context、log、report、embedding,或任何公開物。 +4. **Vault Markdown 是 authoring truth。**Koopa 收到的是 publication snapshot,永不是第二份 authoring copy。修訂 = 改 Vault、送新的 Git blob SHA。發佈 receipt 寫回 Vault。 +5. 不做 embedding、不做知識搜尋、不做公開/admin 搜尋、不做 related-content、不做 graph。既有的 search stack 是移除標的。 +6. **發佈永遠 admin-only。** +7. 前端設計延後,直到產品語意與後端契約定案。 +8. Agent 自主性是中間地帶:不逐項核准,但不得無界掃 backlog、不得任意執行、不得自我驗收。 +9. GitHub 與 Linear 是參考,不是設計邊界。 +10. 不要假設這是小修;大重構可以提,但必須被證明。 + +### 2.2 Locked OUTCOMES(2026-07-16)——**且沒有任何 taxonomy 被鎖定** + +PARA / GTD / Area / Project / Goal / Todo / Inbox 等**全部是可丟棄的假說**。被鎖的只有結果: + +- **O1** 零分類的快速捕捉,而且被捕捉的東西之後仍然能被推進。 +- **O2** 規劃 / 追蹤。 +- **O3** 不逐項核准的有界自主。 +- **O4** agent 不得擴張承諾。 +- **O5** 取消 / 重試 / 逾時 / 效果被誠實回報。 +- **O6** 驗證 / 驗收**不可被產出它的那條 lineage 偽造**。 +- **O7** Vault 來源 / 授權 / 公開效果三者可分離。 +- **O8** diary 永不進入 Koopa。 +- **O9** 外部知識離線時,規劃仍然可用。 +- **O10** 不得退化成通用 workflow engine。 + +### 2.3 Owner 2026-07-25 的裁決 + +- **Obsidian(經 yomihon 讀)是所有知識與文章組織的唯一來源。**Koopa 只持有 publication snapshot;**站內 authoring 路徑移除**。 +- **未被觀察 == 不存在**:「如果沒在用,就跟不存在一樣;沒有觀察、沒有使用,那紅或綠都無所謂。」 +- **生產資料完全可丟。**四個 migration,無 migration 負擔。 +- **Goals 與 milestones:刪。**他說不出它們替他做了什麼。2026-06-24 的 propose_goal owner-LOCK **由他本人重新打開**。 +- **日文是真實的內在拉力,不是為了認證**——「我就是想學好,能讀原文的日本文學。」**因此它不可以被測量**(測量一件內在動機的休閒活動會降低持續)。 +- **泰拳完全沒有機器可讀的痕跡**(和教練口頭約時間),整個排除在系統外。 +- **Koopa 不得讀 vault 的 git log**(owner 原話:不要吧),以保住 lock 2。 +- **通知走 PUSH channel**(Telegram / Discord / Slack)。他已經有一個能動的 Telegram bot。 +- **Whetstone 是未來的實驗性專案**(對 Go/Rust/Docker/k8s 的「分享」姿態),尚未設計,不是依賴。 +- **授權**:清單上的東西全部可刪,可自由重構。他**明確不希望**這個服務在接下來幾天還在跑——先設計、一次刪乾淨、重建,然後才跑。 + +### 2.4 Owner 對本簡報兩個開放分支的裁決(2026-07-25 晚,補入) + +初版簡報把兩件事列為 §7 的開放分支。owner 已裁決,**兩條都升格為 CONSTRAINT**: + +**(a) `deliver: local` 不是刻意設計。** 問他 13 個 job 用 local 投遞是否為刻意選擇,答:**「我沒印象了」**。所以它是一個從未被改過的預設值,不是「我不想被打擾」的決定。 + +推論(INFERRED,但強):**這不是一個需要重新設計才能修的問題,這是一個欄位。** 任何主張「必須重建以解決迴圈不收斂」的設計,必須先解釋為什麼改欄位不夠。反過來說也成立:如果 13 條迴圈的產出從來沒有到達過任何人,那麼「他不用這個系統」這件事,有多少比例其實是「這個系統從來沒有對他說過話」,是**未知的**。沒有人量過。 + +**(b) 現存的 4 篇文章可以不保留;公開面就是個人 blog。** owner 原話:**「那個四篇可以不用保留,obsidian 有想要發布的文章我之後再取捨就好」**、**「koopa0.dev 公開面是個人 blog,為何只有個人 blog,因為我不知道公開什麼比較好.. 就只有個人 blog」**。 + +這句話有兩個獨立的後果,都要納入設計: + +1. **遷移成本歸零。** §6 說「唯一不可逆的一步是把只存在於 DB 的那篇 13,710 字文章導出來」——這一步現在也不需要了。發佈側沒有任何資料需要保存。 +2. **「個人 blog」是一個預設值,不是一個定位。** 他明說之所以只有個人 blog,是因為**不知道公開什麼比較好**。這跟 `deliver: local` 是同一個形狀:**沒有人選過,只是沒有改過。** 一份把「服務個人 blog」當作需求來滿足的設計,是在替一個未經選擇的預設值蓋房子。**你可以、也應該質疑公開面是否該存在**——但不要越界替他決定他要對外說什麼,那是他的。你的工作是讓「之後再取捨」這件事成本夠低。 + +### 2.5 他自己寫下的 push 契約(2026-07-03 自我稽核, `~/.hermes/claude-memory/telegram-push-contract-audit.md`) + +主動推播必須**同時**滿足四條:**①可行動或需決策 ②change-triggered ③fingerprint 去重 ④給正確的 fix hint,否則不給。**(VERIFIED — 這是他自己的稽核結論,當 CONSTRAINT 用。) + +--- + +## 3. 目前的 14 個 MCP 工具(現況,非約束) + +read-only:`brief`(morning/reflection)、`list_todos`(caller-scoped)、`list_content`(caller-scoped)、`review_period`、`project_progress`。 +mutation:`plan_day`(idempotent)、`capture_inbox`(additive)、`propose_area`/`propose_goal`/`propose_project`(additive, inert draft)、`propose_content`(additive, 進 review queue)、`revise_content`(destructive)、`resolve_todo`(destructive)、`set_todo_recurrence`(destructive)。 + +權威在 `internal/mcp/ops/catalog.go::All()`。`as` 參數只做 attribution,沒有 tool-layer 授權;存取邊界是 MCP transport。 + +--- + +## 4. 證據(只有事實。指令與路徑可自行複核) + +## 4.0 勘誤(2026-07-25 深夜,由 Codex 的獨立複核提出,本席逐條實測確認) + +**下列四條在初版 §4 是錯的。它們曾被兩份設計引用,修正後結論有變。** + +- **E40 錯了 —— main 有保護。** 初版寫「`gh api .../branches/main/protection` → 404 Branch not protected」。404 是真的,但那是**舊版 endpoint**;repo 自 **2026-07-11** 起有 active **ruleset**(id `18798526`):要求 PR、5 個 required checks(`Build / Vet / Test`、`Integration (race, testcontainers)`、`golangci-lint`、`sqlc drift`、`Frontend build + lint`)、`bypass_actors: []`。(VERIFIED,本席實跑 `gh api repos/Koopa0/koopa/rulesets/18798526`) + **但 O6 的結論不變,理由換了**:`required_approving_review_count = 0`、`require_code_owner_review = false`、`require_last_push_approval = false` —— 持 token 的 agent 可以開 PR 然後自己 merge。**修法因此比初版說的更便宜:骨架已在,只差把 approval count 調到 ≥1 + 開 code-owner review。** + +- **E32 被過度解讀 —— 那個 pattern 從未跑通過一次。** 初版拿 `cron-sync-vault-wrapper.sh` 的 38 次執行當作「owner-gated publish 已在生產運作」的證據。實測 38 份報告:**38/38 全部停在 branch gate**,逐字「已有未合併的 sync-vault 分支,先等 Koopa merge,跳過本次」,阻塞的分支 `hermes/sync-vault-2026-06-17-1333-go` 已等 **5 週**。**零次到達 check / render / commit。**(VERIFIED) + **後果最重:這是同一個病理的第五個實例** —— 一條迴圈跑了 38 次、每次回報成功、而它唯一的出口是 owner merge,那個 merge 從未發生。任何設計若拿它當「已驗證可行的 pattern」,拿到的是 **code pattern,不是跑通證據**。 + +- **E34 減弱 —— 那份 build output 不在版控裡。** `git ls-files 'frontend/dist/**'` → **0**;`frontend/.gitignore:4` 忽略 `/dist`。所以「已 commit 的 build output 含完整 SEO bytes」不成立 —— 檔案在本機存在,但**不能證明現在的 HEAD 重建得出來**。4 條靜態頁的 prerender 宣告是真的,**article route 的 prerender 是 0**。(VERIFIED) + **後果**:任何靜態化設計都必須先做**一篇文章的 build spike**,才能引用 SEO 論證。 + +- **E39 已漂移且持續惡化。** Telegram `getUpdates` conflict 不是 130+,實測 **1,799** 且仍在增加(Codex 複核時 1,767)。(VERIFIED) + **後果**:單一 poller 是 capture 遷移的**前置條件**,不是待辦事項。 + +另:`~/obsidian/Writing/articles/` 是 **7 篇、全部 `status: ready`**(非 4 篇)。所以 **`ready` 是編輯狀態,不是發佈意圖** —— 任何以 `ready` 為 gate 的自動 pipeline 第一次跑就會錯發 7 篇。 + +--- + +### 4.1 機械健康 + +- **E1** `go build` / `go vet` / `golangci-lint` / `staticcheck -checks=U1000` 全綠。165/165 個 sqlc query name 都有 caller。~28.6k 非測試 Go LOC 中只有 **1 個**真正不可達的 function。無違禁依賴。(VERIFIED) +- **E2** 規模:非測試 Go 27,252–28,600 LOC(口徑差異;其中 8,179 是 sqlc 產生)、測試 23.4k、frontend 37.1k(admin ~21.7–22.0k、public pages 13,057)、17 tables、4 migrations、82 routes、93 個 go.mod 依賴。(VERIFIED) +- **E3** `internal/` 60 個非產生非測試檔共 13,938 行:doc comment 16.2% / in-function 2.6%;Go stdlib 對照 16.3–17.6% / 7.7–15.8%。(VERIFIED) + +### 4.2 三段長期停滯的記錄 + +- **E4** plan-day cron:`~/.hermes/cron/output/1a3b0f90d594/` 有 26 筆。2026-07-02 → 07-25 之間 19 次成功執行輸出**同一段 byte string**: + ``` + 今日主線(草案,去 admin 精修): + 1. Kotonoha 句層擴充:phrases 35→~80(は/を/へ 文法句優先)(在途) + ``` + 另有 3 次 log 出 `plan_day write failed`,`last_status` 仍記 `ok`。(VERIFIED) +- **E5** proposal-loop:158–159 次完成(自 2026-06-17 每日 5 次),全部 `last_status=ok`。保留的 50 筆中 **46 筆**是 `new=0 reconfirmed=0 pruned=0 removed=0` 且 `llm_no_output=1`;4 筆有 ledger 移動;2 筆產生新 proposal。36 個 proposal 卡在本地 `seen >= 2` 的 gate 後面,而一個不產生輸出的 loop 永遠無法滿足它。(VERIFIED) +- **E6** go-health-scan:`~/.hermes/cron/jobs.json` job `5e1197bfd862`,`no_agent: True`、`script: cron-go-health-wrapper.sh`、`0 14 * * *`、created 2026-06-17、29 次完成、`last_status=ok`。它對 7 個 Go repo 跑 `build / vet / lint / govulncheck`——**純 shell,沒有 LLM 在迴圈裡**。解析全部 29 筆報告:`go-spec-test errcheck:5`、`blog goconst:1`、`landscape-go lint` 在**實際掃到它們的 23 次中每一次都是紅的**(2026-06-23 → 07-24,32 天),無一被修;其中兩個是一行修復。script footer 重複 29 次:`修復交 Claude;此巡檢唯讀不代改`。(VERIFIED) + +### 4.3 一個 verifier 與其後續工作的時序 + +- **E7** kotonoha:`test/properties/koten_dataset_integrity_test.dart` 對每個季節斷言 `greaterThanOrEqualTo(2)`,comment 寫 "~6 each is the floor"。git 順序已查證: + ``` + 2026-06-03 17:58 ecef352 加入 season floor guard + 2026-06-04 12:17 319187a feat: koten summer/autumn corpus to ~6 each… + (+88 行進 koten_dataset.dart,未動 test 檔) + ``` + **predicate 先,工作在 ~18 小時後,且工作沒有碰測試檔 → driver 而非 ratchet。**(VERIFIED,n=1) +- **E8** `.github/workflows/ci.yml` 在 `push` 與 `pull_request` 都跑 `flutter test`。(VERIFIED) + +### 4.4 投遞欄位 + +- **E9** `~/.hermes/cron/jobs.json` 的 `deliver` 欄位:15 個 job 中 **13 個是 `local`**(含 go-health-scan 與 plan-day)、**2 個是 `telegram`**(pa-brief、brainstorm)。`local` = 寫成一個他必須自己去開的檔案。(VERIFIED) +- **E10** transport 已存在:`hermes send --to telegram`(`hermes_cli/send_cmd.py`)、job 層的 `deliver: telegram`。(VERIFIED) +- **E11** `cron-common.sh:34-43` 用 EXIT trap 記錄真實 exit code 與 duration(其註解自陳它修掉的謊:`pa-brief 還把 ledger 寫死 exit 0 說謊`);`:46-57` 的 `cron_init` 有帶 liveness 檢查的 pidfile lock。`cron_skip` 目前 exit 0,ledger 記成功,note 欄帶 `skip:` 前綴。(VERIFIED) + +### 4.5 MCP 面的實際使用量 + +- **E12** `grep -rhoE '"name":[[:space:]]*"mcp__koopa0[-_]knowledge__[a-z_]+"' /Users/koopa/.claude/projects/`(3,255 個 `.jsonl`,mtime 2026-06-17 → 07-25): + `search_knowledge` 27、`brief` 21、`project_progress` 18、`list_content` 11、`review_period` 9、`capture_inbox` 8、`list_todos` 5、`propose_project` 4、`revise_content` 2、`propose_content` 2、`resolve_todo` **1**、`list_tasks` 1。合計 **109**。`plan_day` / `set_todo_recurrence` / `propose_goal` / `propose_area` = **0**。唯一那次 `resolve_todo` 發生在 `-Users-koopa--hermes` 的 session,不是 koopa0.dev 的。**這是 agent 端的 MCP 呼叫,不含 owner 自己在瀏覽器裡的操作**(見 §7 U1)。(VERIFIED) +- **E13** `capture_inbox` 另有 35 次來自 hermes cron 的推送(ledger 的 `pushed: yes` 只在 `koopa0-capture.sh` exit 0 時寫)。它是整個 koopa 面上**唯一有持續跨行程流量**的工具。(VERIFIED) + +### 4.6 同一份帳本存在三個地方 + +- **E14** `~/.hermes/proposals/inbox`:36 個 `.md`,36 個 `status: pending`、35 個 `pushed: yes`、35 個 `surfaced: yes`、**0 個** `koopa0_dup: yes`。`~/.hermes/kanban.db`:47 tasks(34 ready / 10 blocked / 3 archived)、118 task_events、**2 task_runs**。`cron-proposal-loop-wrapper.sh:149-152` 自陳 kanban mirror 是「實驗,index-only…兩週後評估是否真用,沒跨-artifact 視圖價值就撤」。koopa 的 `todos` 表透過 `koopa0-capture.sh` 收到同樣那 35 筆。(VERIFIED) + +### 4.7 跨行程的字串契約已經斷過兩次 + +- **E15** `11ca2a84`(06-19)「feat(mcp): add list_tasks read tool for proposal readback」;`6164bc58`「add resolve_task for proposal-readback self-close」;`4d73cd14`(06-25)`refactor(mcp)!: rename agent task vocabulary to todo`。`~/.hermes/pylib/hermes/proposal/readback.py:211` **至今仍呼叫 `list_tasks`**;`.readback-seen.json` 的 mtime 停在 2026-06-26 12:08(而 `save_seen()` 每次成功 fetch 都會寫,cron 每天跑 5 次);字串「readback 偏置」在整棵 `~/.hermes/cron/output/` 出現 **0 次**,橫跨其後約 145 次執行。`koopa0_mcp.py:418` 的 dedup 路徑已改成 `list_todos`,readback 沒改。(VERIFIED) +- **E16** `1ad6bea8`(07-18)刻意退休 `search_knowledge`。`~/.hermes/pylib/hermes/common/system_state.py:249` 仍在 `_real_koopa0_probe` 裡呼叫它;system-state cron(`0 */3 * * *`, enabled)於 `~/.hermes/cron/output/612dfc20f5ce/2026-07-23_09-33-29.md` 產出:**「koopa0 缺必備工具:search_knowledge(可能是部署退版或 server 無法連線,需要確認) → 已推 telegram」**。`~/.hermes/desired-state.yaml`(標頭「Koopa 手寫」)的 `koopa0_required_tools` 列著 `brief, list_todos, resolve_todo, propose_goal, propose_area, search_knowledge`。(VERIFIED) + +### 4.8 koopa 與 repo / git 的關係 + +- **E17** `grep -rn "exec.Command" --include=*.go internal cmd | grep -v _test` → **0**。非測試檔 import `os/exec` → **0**。所有 `activity_events` 由 5 個 AFTER trigger 產生(migrations `001:909, 929, 951, 977, 1004` 與 `004:83`),對象是 koopa 自己的 todos / goals / milestones / projects / contents。30 天內約 **243 個 commit** 在系統內產生 **0** 個 event。`project_progress` 因此把 6/6 個 area 報成 neglected。(VERIFIED) +- **E18** 全部 114 個 vault commit、抽樣的 100 個 koopa0.dev commit,作者都是 `Koopa `,**包含** subject 像 `hermes: rust batch 06-24` 的 agent 產出。`.claude/rules/git-workflow.md` 禁止任何 attribution line(含 Co-Authored-By)。5 個 hermes cron 會碰 git。(VERIFIED) → 往後可用不同的 `GIT_COMMITTER_EMAIL` 區分(該規則禁的是 message trailer,不是 committer identity);過去的不可回復。(INFERRED) +- **E19** yomihon 的 `(via yomihon)` commit 後綴不是人類訊號:`internal/status/status.go:109` 是 `const actor = "koopa"`(compile-time 常數),`POST /status` **無任何驗證**,裸 `curl` 就能產生一個 commit;該 commit 帶 vault 自己的 git identity、無 trailer、無 note、無簽章。其決策日誌記的字串是 `(via kurodo)`(改名前)。`yomihon check` 目前對真實 vault 是死的(exit=2, "privacy authority unavailable")。(VERIFIED,由 yomihon 團隊直接實驗) + +### 4.9 audit trigger 的實際保證 + +- **E20** `migrations/001_initial.up.sql:867-871` 明文寫著:直接 `INSERT INTO activity_events` **不觸發 trigger 且被 DB 接受**——「是慣例違反…不是 schema 擋得住的」。`current_actor()`(`001:874-888`)在 `koopa.actor` 未設時回傳 `'human'`。任何 migration 都沒有 `activity_events` 的 BEFORE INSERT guard。`project_progress` 只計 `actor='human'`。`CLAUDE.md` 寫的「應用層寫入會被攔截」與 `001:867-871` 不一致。(VERIFIED) + +### 4.10 四個「存在但從未接線」的驗證器 + +- **E21** 卡了 24 天的那筆 todo 是「Kotonoha 句層擴充:phrases 35→~80」,而 + `grep -c "Phrase(" /Users/koopa/flutter/japanese-learn/lib/domain/data/phrase_dataset.dart` → **35**。done-predicate 是一行 grep,回傳的正是那筆 todo 標題裡的數字。從未接線。(VERIFIED) +- **E22** `.claude/settings.json:166` 在**每一次 git commit** 觸發,指示 agent 呼叫 `mcp__koopa0_knowledge__log_dev_session`——該工具在 `internal/mcp/ops/catalog.go` 出現 **0 次**。git history 顯示每天 4–63 個 commit。他工作日裡頻率最高的那一刻,靜默失敗了好幾個月。(VERIFIED) +- **E23** `projects.expected_cadence` 在每一個 project 都是 NULL 且永遠會是:兩條 INSERT 路徑都不寫它,唯一寫入路徑是 `UpdateProject` 的 COALESCE,而 `grep -rni cadence frontend/src/` → **0** 命中(對照 `milestone` 約 14 個前端檔)。`internal/project/progress.go:167-172` 的 `Stalled()` 因此把 NULL 解成空字串、對不到 cadence map、**無條件回 false**。(VERIFIED) +- **E24** `phrase_dataset.dart` 101 行、35 個 `Phrase(`。現有 `test/phrase_test.dart` 與 `test/particles_test.dart` 只斷言:單 rune 的真平假名、無拗音促音、romaji/meaning 非空、若出現 particle 則有 gloss。**phrase 沒有任何 uniqueness 斷言**(confusable / quiz / daily_session 有)。35 條中只有 **4 條**含 は/を/へ——那筆 todo 真正說的內容(`は/を/へ 文法句優先`)**今天不被任何東西測量**。(VERIFIED) + +### 4.11 發佈側的實況 + +- **E25** `curl https://koopa0.dev/api/contents?limit=100` → 200,`meta.total = 4`,全部是 `article`:`bounded-autonomy-personal-agent-os`(2026-07-03, 13,710 chars)、兩篇 2026-06-26(2,088 / 2,308)、一篇 2026-06-25(3,156),合計 **21,262 chars**。sitemap 4 個 URL。`review_period since=2026-01-01` 的 `counts.content_published = 4`。5 種 content type 有 4 種從未被使用過一次。最後一次發佈是 2026-07-03。(VERIFIED,三個獨立面互相印證) +- **E26** 以 claude / human / hermes / codex 四個 caller 查 `list_content`:4 筆中 3 筆 `created_by=NULL`(即 migration 003 的 COMMENT 自陳要退休的 direct-admin authoring 路徑);第 4 筆 `created_by='claude'` 但 `source_vault_path=""`。**每一篇被發佈過的文章都走了 003 關掉的那條路;003 規定的那條路發佈過 0 篇。**(VERIFIED) +- **E27** migration 004 的 data UPDATE 目標是 `status='published' AND NOT is_public`;4 筆皆 public,故轉換 **0** 筆。它的 immutability trigger 與 7 個 withdrawal/source 整合測試,守護一個沒有生產實例的狀態轉移。(VERIFIED) +- **E28** lock 4 的 Vault receipt **不存在**:`render_hash` / `content_hash` / `writeback` 在 tracked source 是 0 命中;`list_content` 的工具描述自陳 "Koopa itself never writes the Vault",並指向一個從未被建造的 "optional external Vault writer"。(VERIFIED) +- **E29** `~/obsidian/Writing/` 有 163 個 `.md`(articles + lessons),30 天內 151 個被改動。`articles/` 有 **4 篇 `status: ready` 從未發佈**。frontmatter 是 `title / topics / status / created / updated`;生產 slug = slugify(title)(DB slug `reliability-scalability-maintainability-重新理解-go-開發的三個維度` 恰為 frontmatter title 的 slugify)。`Writing/lessons/japanese/` 27 個檔、16 個 frontmatter key,**沒有一個是關於學習者的**;`status` 只有 draft(22) / ready(5),是伴讀材料的編輯狀態。(VERIFIED) +- **E30** `bounded-autonomy-personal-agent-os`(13,710 chars = 公開語料的 **65% by bytes**,且是 `pages/hire/hire.html:80-96` 上的 **#1 credibility Receipt**)在 `~/obsidian/Writing/` **不存在**,只活在 DB 裡。(VERIFIED) +- **E31** Whetstone = `~/go/src/github.com/koopa0/learning`,origin `Koopa0/whetstone.git`。它的 `sync-vault-publish` cron **38 次執行發佈 0 篇**,自 2026-06-18 起被一個 5 週未 merge 的 branch 擋住(該 branch 的整個 diff 是 110 個檔 +121/−121 的 metadata churn);其 `content/` 自 2026-06-10 凍結;它取的是 `lessons_dir`,與 `articles/` 是不相交的語料。(VERIFIED) +- **E32** `cron-sync-vault-wrapper.sh` 已經實作出一整套 owner-gated 發佈:`status: ready` frontmatter 當 owner gate、決定性的 `-check` 驗證器、out-of-bounds verifier(第 96-104 行:diff 若碰到 content 目錄以外就中止且**不 commit**)、未 merge branch 的 backpressure(45-60)、**human merge 作為 accept action**。37–38 次執行。第 20 行 `LEARNING=/Users/koopa/go/src/github.com/koopa0/learning`——它發佈到 Whetstone,不是 koopa0.dev。(VERIFIED) + +### 4.12 公開面與 SEO + +- **E33** 82 routes = 7 個公開 content GET(`cmd/app/routes.go:114-120`)+ 6 個未驗證的 infra/auth(`/metrics` `/healthz` `/readyz` 與 3 個 `/api/auth/*`)+ **69 個**在 `authMid`/`adminMid` 後面。公開頁 **0 個 POST**。無 search(2026-07-18/19 退休)。`pages/privacy/privacy.html:41-45` 明文宣告**不做任何 analytics**。(VERIFIED) +- **E34** `/about` `/hire` `/privacy` `/terms` **已經**是 `RenderMode.Prerender`(`app.routes.server.ts:26,31,36,41`)。已 commit 的 build output `frontend/dist/koopa0dev/browser/about/index.html`(46 KB, 7/20)的原始 bytes 內含 ``、`meta description`、完整 `og:*`、`rel=canonical`、`application/ld+json`。**在這個 codebase 裡,prerender 已經產出完整的 SEO byte stream。**article route 的差別只在於資料要在 build time 取得。(VERIFIED) +- **E35** `ApiService`(`core/services/api.service.ts:14-18`)是唯一的 server/browser 分岔點(`isPlatformServer ? ssrApiUrl : apiUrl`)。`angular.json:21` 是 `outputMode: "server"`;`angular.json` 已把 `public/**/*` 複製進輸出。(VERIFIED) +- **E36** 2026-07-03 的 article deep-link `Cannot GET` 事故是 **SSR-only 的失效模式**(nav error 被吞 → `handle()` null → Express finalhandler);對著磁碟上的檔案不會發生。(VERIFIED,歷史) +- **E37** `docker-compose.yml` 注入 **10 個沒有任何 Go 檔讀取**的 secret:`R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` / `R2_ENDPOINT` / `R2_BUCKET` / `R2_PUBLIC_URL` / `ANTHROPIC_API_KEY` / `LINE_CHANNEL_TOKEN` / `LINE_USER_ID` / `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID`。`frontend/server.ts` 把 `x-hub-signature-256` / `x-github-event` / `x-github-delivery` 轉發到一個**不存在**的 backend webhook route。(VERIFIED) + +### 4.13 capture 與 Telegram + +- **E38** 接收端是官方 Claude Code Telegram plugin(`~/.claude/plugins/cache/claude-plugins-official/telegram/0.0.6/`),用 **`getUpdates` long-polling**(不是 webhook),allowlist 在 `~/.claude/channels/telegram/access.json`。**inbound capture 不需要 listening socket。**(VERIFIED) +- **E39** `~/.hermes/ops/fable-advisory-2026-07.md:49` 記錄(標 verified):errors.log 有 **130+ 次 `getUpdates` conflict** —— 2 個以上的 instance 在爭同一個 bot token。`~/.hermes` 內沒有任何東西呼叫 `getUpdates`(grep 只命中這些 advisory 文件),所以 hermes 的 `deliver: telegram` 是 send-only,polling 完全來自 plugin。同文件 line 63 記錄:這條 inbound channel 開通時**沒有做 lethal-trifecta / untrusted-input 審查**(RSS 有做)。(VERIFIED) + +### 4.14 O6 邊界今天的可執行性 + +- **E40** `gh api repos/Koopa0/koopa/branches/main/protection` → **HTTP 404 "Branch not protected"**。`gh auth status` → Koopa0,token scopes `gist, read:org, repo`,存在 keyring,**此 OS 帳號的任何 process 都能用**。main 最近 60 個 first-parent commit 中 **28 個是 direct push**,不是 PR merge。`.github/workflows/ci.yml` 是同一個 repo 裡的 tracked file。`cron-go-health-wrapper.sh` 是 0755、owner `koopa`,與 agent 同帳號。(VERIFIED) + +- **E42** `CLAUDE.md:178` 寫「應用層寫入會被攔截」;schema 自己在 `migrations/001_initial.up.sql:869-871` 逐字說 direct `INSERT INTO activity_events` **is accepted by the DB — not something the schema blocks**。且 `current_actor()`(001:874-888)在 `koopa.actor` 未設時**回傳 `'human'`** — 任何忘記 `SET LOCAL koopa.actor` 的寫入會被記成 **owner 本人**。(VERIFIED,兩處直接衝突) + +**後果:** O6 是 locked outcome,而**現況沒有任何機制在提供它** — Postgres 沒有(E42),git 也沒有(E40)。任何主張「保留 Postgres 是為了 O6」的設計是錯的;任何主張「改用 git 就失去 O6」的設計同樣是錯的。**兩邊都從零開始。**最便宜的候選是 branch protection + required status check + 一個權限低於 admin 的 agent token — 免費,且不是資料庫。 + +### 4.15 刪除時會連鎖的具體事實(若你的設計要動 schema/package) + +- **E41** `internal/goal` 之外對 `goal.*` 的引用共 31 個 symbol。其中兩個與 goal 無關:`goal.ContainsControlChars` 被 `internal/content/admin.go`、`internal/mcp/capture.go:103`、`internal/mcp/content_tools.go:173,176`、`internal/mcp/validate.go` 使用;`goal.DeriveSlug` 被 `internal/mcp/proposal.go:74, 215, 316` 使用,**其中 :316 是 propose_project**。(VERIFIED) +- **E42** `cmd/app/routes.go:158-160, 180-181` 把 areas 的整個 HTTP surface 掛在 `h.goal` 上(`ListAreas` / `AreaDetail` / `CreateArea` / `ActivateArea` / `RejectArea`)。(VERIFIED) +- **E43** `area_id` 在 `internal/db` 之外的**完整** reader 集合:`internal/goal`、`internal/project/query.sql` 的 progress queries、`internal/stats` 的 `StatsGoalsByArea` + `StatsEventsByArea`(餵 `computeAreaDrift`, `store.go:214`)、`review_period`。`activity_events.area_id` 的 COMMENT 自陳「Powers the area neglect/activity rollups in project_progress and review_period」。`migrations/002_seed.up.sql:3` 寫「PARA Areas are intentionally NOT seeded: areas are personal life-domains」。(VERIFIED) +- **E44** audit function 位置:`001:874 current_actor`、`891 audit_todos`、`914 audit_goals`、`934 audit_milestones`、`956 audit_projects`、`982 audit_contents`——**但現行的 `audit_contents` 定義在 `migrations/004:12-43`,不是 001**(改到 001 那份等於沒改)。`activity_events` 帶 `area_id UUID REFERENCES areas(id)`(`001:802`)與 `idx_activity_events_area`(`001:852`)。(VERIFIED) +- **E45** pgvector **確實被 import**:`internal/db/models.go:14`,`Content.Embedding *pgvector_go.Vector`(:439);`sqlc.yaml:67-74` 把 `db_type: vector` 映到該型別,`:78` 把 `contents.search_vector` 釘成 string;`internal/testdb/testdb.go:42` 把容器 image 釘成 `pgvector/pgvector:pg17`;`001:429` COMMENT 提到的 `internal/embedder` **不存在於磁碟**。**沒有任何 `query.sql` 參照 `embedding` 或 `search_vector`**。(VERIFIED) +- **E46** runtime 實驗(PostgreSQL 18.4):jsonb 減 `text[]` 對不存在的 key 靜默忽略;`ALTER TABLE ... DROP COLUMN search_vector` 不引發相依性錯誤(該字串只是 array 內的 text literal);drop 之後 `guard_content_withdrawal_metadata`(`004:97-113`)兩個分支行為不變。(VERIFIED — 修正了一個原本被當成必要步驟的假前提) +- **E47** `internal/url`(204 LOC)的唯一非測試 importer 是 `internal/feed/collector/collector.go`。`internal/feed` 非測試 1,926 LOC;`scheduler.go:103-115` 是 ticker 驅動的**自動** ingest;但 `POST /api/admin/knowledge/feed-entries/{id}/curate` 與 `/ignore`(`routes.go:244-245`)是**手動** admin 端點,`Curate` 的作用是把 feed entry 連到一個 content row(`feed/entry/query.sql:43-48` 設 `status='curated', curated_content_id=...`)。(VERIFIED) +- **E48** `internal/mcp` 有一個從未被讀取的欄位 `stats *stats.Store`(`server.go:27` import、`:47` 宣告、`:88` 建構)。U1000 因為 composite literal 賦值算 use 而不報。(VERIFIED) +- **E49** `frontend/src/app/app.routes.ts:83-314` 有 26 個非 redirect 的 admin route 加 7 個純 redirect。25 個 frontend 檔引用 `commitment/goals` 或 `commitment/areas`。`internal/today`(`today.go:40-45`)自陳 day-progress strip「never depends on a committed plan existing」、`CommittedTodos` 是 "optional pin only"。`StatsDatabaseCounts`(`stats query.sql:123-128`)只數 contents 與 todos。(VERIFIED) + +### 4.16 文件狀態 + +- **E50** `docs/`:19 個 tracked 檔 3,537 行。code-tracking 7 檔 1,213 行 **98 commits**;dated records 9 檔 2,124 行 12 commits;owner rulings 1 檔 175 行 2 commits。**35% 的行吃掉 87% 的 commit。**`backend-semantic-contract.md` 同時是**維護最勤**的檔(42 commits)**與漂移的那個**(抽樣 12 條 claim 有 2 條假 —— 它列了兩個不存在於任何 migration 的 FK 欄位);commit `8cce8389` 依照 repo 自己的維護規則在**同一個 commit** 改了 code 與 doc,doc 仍然是錯的。`docs/work-orders-2026-07.md`(57 KB)20 項只完成 2 項,且明文「保護」`internal/search`、`internal/embedder`、`SimilarContents`、`/admin/knowledge/search` —— **四者皆不存在**;它自己的標頭已經寫了 expiry rule 與 maintenance rule,兩條都失效。`.claude/`:170 個 `.md`、29,459 行、~5.7 MB、**0 tracked**(gitignored,刪除不可回復)。(VERIFIED) + +### 4.17 外部證據(harness vs model) + +- **E51** Anthropic 在**固定的 two-tool scaffold** 下只換模型,把 SWE-bench Verified 從 22% → 33% → 49%,並主張 "keep the scaffolding minimal"。METR:harness 變動移動 time horizon 以**分鐘**計,模型世代以**小時**計;已測得的 harness engineering 價值是 **+4.7 ~ +7.7 分**;80% 成功率的 horizon 約為 50% horizon 的 **1/5** —— 在前沿約等於**一小時**的人類等效無人值守工作,而且全部量在**有 verifier 的領域**。Karpathy 把 agent 失敗歸因於模型認知而非流程設計;他自己的 loop artifact(autoresearch)跑在前沿模型上,力量來自:一個 scalar metric、一個 **agent 被禁止修改**的 evaluation harness、git keep-or-reset、append-only ledger。(VERIFIED as external citation) + +### 4.18 節奏與先前的探索 + +- **E52** `review_period since=2026-01-01`:206 天內 **2 個完成的 todo、8 個 active day**。注意該工具只計 `actor='human'`,且計的是**完成事件**,不是**閱讀**。(VERIFIED) +- **E53** 先前一次 32-agent 的盲測探索評估了六個候選模型,結論是**六個全部塌陷**:每個都在自己的 schema 裡重新長出一個 container,且沒有一個能回答「這個月我在做什麼」。其判詞:zero-taxonomy 不是移除 taxonomy,是移除**可維護的** taxonomy —— 成本從 schema 移到 owner 的手指,並失去 rename / index / merge / cascade。(VERIFIED as prior finding;作為對未來的預測則是 INFERRED) + +--- + +## 5. 一種讀法(受測結論)+ red team 判決 —— **請攻擊它** + +### 5.1 被測的結論 + +一位 assistant 把上述證據綜合成: + +> **「koopa = 一個對 repo work 的 predicate loop + publication。沒有規劃層。」** + +由此導出的刪除清單:goals、milestones、`projects.goal_id`、`goals.quarter`、`internal/goal/`、`daily_plan_items` + `plan_day`、`project_progress`、`review_period`、`propose_area`、`propose_goal`、search stack(pgvector extension、`contents.embedding`、`contents.search_vector`、GIN+HNSW indexes、pgvector-go 依賴)、progress bar,以及後來追加的 `areas`(理由:areas 是 life-domain container,但所有非軟體領域都已離開系統,只有一列的 container 不是 container)。MCP surface 14 → 9。保留:todos、`activity_events`、FlexInt、publication path。 + +### 5.2 四個 red team 的判決:**BROKEN** + +拆成三個子句計分: + +- 「**沒有規劃層**」→ **成立**(四隊從獨立證據支持)。 +- 「**+ publication**」→ **作為功能成立**;作為「現有機械正當」的理由不成立。 +- 「**對 repo work 的 predicate loop**」→ **破了**,而且破了兩次,理由互相獨立: + +**破法一(E17)**:`grep -rn "exec.Command" --include=*.go internal cmd | grep -v _test` → 0。koopa 讀不到 repo。所以那個子句**不是刪除後的剩餘物,是被放在保留清單上偽裝成保留物的全新建造**。在 convergent over expansionary 之下,新建的東西必須自證,不能因為挨著刪除清單就繼承正當性。 + +**破法二(E6 vs E7)**:即使把 repo reader 蓋出來,這個結論預設的機制「**有 verifier,工作就會動**」已經被證偽。`go-health-scan` 滿足「客觀 + 不可被 LLM 幻覺(純 shell)+ owner 零動作」三條,跑了 29 次,三個紅燈在 32 天內每一次都紅、無一被修,其中兩個是一行修復。 + +**但**:E7 的方向已被查證為 driver 而非 ratchet(predicate 先、工作 18 小時後、且工作沒碰測試檔)。所以正反並存,reconcile 的結果是: + +> **verifier 是必要但不充分。**E6 與 E7 只差兩個變項,兩個都不是「有沒有驗證」: +> **(a) delivery** —— `deliver: local` 寫進一個他不會去開的檔案 vs CI 紅燈擋住 PR; +> **(b) consequence** —— 一句寫給沒有人的交接(`修復交 Claude;此巡檢唯讀不代改`,寫了 29 次)vs 一個被擋住的 merge。 +> +> 證據權重:正案例 n=1,反案例 3 個紅燈 × 32 天。**沒有任何一隊跑過那個能分辨的實驗。** + +**還有一個範疇錯誤**:那道「只有具備快速客觀、agent 不可編輯的 verifier,且證據在 owner 不做任何事的情況下出現,才可以 loop」的門檻,被當成了「什麼東西可以存在於系統中」的成員資格測試。用成那樣,它會刪掉 lock 6 明文鎖住的 publication path(發佈**依設計**需要 owner 動作),也會排除他最大的實際產出流(E29:163 個檔、30 天內 151 個被改)。**它是「哪些領域可以被自主 loop」的謂詞,不是「哪些輸入可以存在」的謂詞。** + +**BROKEN 在這裡的意思是「刪得不夠」,不是「有東西該救回來」。**破解方向加上了更多刪除(todos、`capture_inbox`、`resolve_todo`、`list_todos`、`set_todo_recurrence`、`brief`、`propose_project`、`internal/feed`、`internal/url`,最激進的分支上是整個 Go service 與 Postgres),**沒有救回任何一項**。 + +### 5.3 現在檯面上有兩個活讀法。也歡迎第三個。 + +**Branch A —— 服務消失。**koopa 收斂成「Vault → PR → merge → receipt」的靜態發佈,加上 repo 各自的 CI 當 predicate、既有 hermes cron + Telegram 當投遞。刪 Postgres、`cmd/app/`、`internal/`、`frontend/src/app/admin/`、`server.ts`,`outputMode: "server"` → `"static"`,MCP → 0 或只留 stdio。支撐它的最強新證據是 E34(prerender 的 SEO byte stream 已經在磁碟上被驗證)與 E32(owner-gated 發佈 pattern 已經在生產跑了 38 次)。 + +**Branch B —— 服務留下但被削。**執行 §5.1 的刪除清單,加上 red team 補的修正(見下),MCP 收到 5–8 個,19/26 個 admin 畫面存活。 + +**Branch B 若被選,以下修正是強制的(不做會編不過或靜默失效)**: +- `internal/goal` 不能整包刪 —— 先把 `goal.ContainsControlChars` 與 `goal.DeriveSlug` 搬走(E41),否則會打斷正好要保留的那幾個工具。 +- areas 的 HTTP surface 就住在 `internal/goal` 裡(E42),兩者是**同一次**刪除。 +- `activity_events` 的 trigger 手術完全不在原清單上(E44):DROP `audit_goals`+trigger、`audit_milestones`+trigger;`CREATE OR REPLACE` `audit_todos`、`audit_projects`、以及**從 `migrations/004:12-43` 取來的** `audit_contents`;DROP `activity_events.area_id` + FK + `idx_activity_events_area`。這是整個 Branch B 最大的一塊手術。 +- search stack 的順序是強制的(E45):drop 欄位 → `sqlc generate` → `go mod tidy` → **在同一個 commit 裡 commit `internal/db`**(sqlc-drift CI 釘 v1.30.0);並把 `internal/testdb/testdb.go:42` 從 `pgvector/pgvector:pg17` 換成 `postgres:17`。 +- 刪 areas 的理由必須改成「reader 集合為空」(E43),不能用「只有一列」—— 後者同樣適用於 `projects`,會讓清單自相矛盾。 +- 站內 authoring(`content.Create`/`Update`, `admin.go:172,206`)與 review 動作(send-back / withdraw / restore / publish)**住在同一批檔案裡**,必須切開,不能整包保留或整包刪。 + +**如果你認為兩個分支都錯,請提第三個,並明講它靠哪幾條 VERIFIED 證據站住。**這比選邊有價值得多。 + +### 5.4 什麼證據會推翻哪一邊(拿去用) + +| 讀法 | 什麼會推翻它 | +|---|---| +| Branch A(服務消失) | §7 U1 的 histogram 顯示 owner 每天在讀 admin —— 所有「他不用了」的量測都是 agent 端的(E12) | +| Branch A | §7 U3:那 4 篇 `status: ready` 是**編輯決定**而非摩擦 —— 那就是在拆一個能動的系統去修一個不存在的問題 | +| 「delivery 是關鍵變項」 | §7 U2 的一週實驗跑完,紅燈**沒有**關掉 —— 那缺的就不是觸達,是「一個被綁在 verifier 輸出上、有權行動、不行動有後果的 actor」,而那個東西沒有人蓋出來 | +| 「刪掉規劃層」 | 出現任何一個真正被接線的 verifier,使得「誰持有 predicate 與它的上一個值」重新變成問題 | +| 「檔案足以取代 DB」 | 出現第二個人或第二台機器 —— 所有關於併發的論證都預設一個人一台筆電 | +| 整份設計 | E53:六個候選模型全部塌陷在「這個月我在做什麼」;如果你的設計能回答它,請說明它靠什麼**不需要**持續分類勞動 | + +--- + +## 6. 你必須回答的設計問題(兩份答案要能並排比較) + +**Q0(先答)**:你的設計假設 §7 D1–D4 的哪一個分支?逐條寫明。兩份設計若假設不同分支,因為與設計品質無關的理由而不可比 —— 先聲明就可讀了。 + +**Q1** koopa 這個服務存在嗎?如果存在,它的最小職責是什麼?如果不存在,lock 4 的「Koopa 收到 publication snapshot」由什麼滿足? + +**Q2** 有沒有 Postgres?如果有,**逐表逐欄**列出,並對每一張表回答「誰讀它、多久讀一次、不存在會怎樣」。如果沒有,逐項說明現在 17 張表各自的職責由什麼承接。 + +**Q3** agent 面是什麼?MCP 工具幾個(0 也是答案)、每個的 writability、以及**為什麼這個不能用 Bash/Read/Write/Edit/`gh` 做**。若你保留 MCP,請說明你怎麼避免 E15/E16 那類跨行程字串契約斷裂。 + +**Q4** owner 面是什麼?他已經不開 admin 了(E12 是 agent 端量測,見 U1 的限制)。你的設計把他放在哪兩三個他本來就會去的地方?每一則主動推播如何滿足 §2.4 的四條? + +**Q5** predicate(如果你的設計有)住在哪裡、由誰關閉、不關閉的後果是什麼?請直接回應 E6(有 verifier 但 32 天不動)與 E7(有 verifier 且 18 小時內動起來)之間的差別。 + +**Q6** 「什麼都沒發生的那一天」系統輸出什麼?那與「checker 壞掉」在你的設計裡如何區分?(E4 三次 `plan_day write failed` 仍記 `ok`;E5 158 次 `ok` 中 46/50 是 `llm_no_output=1`。) + +**Q7** O6 在你的設計裡怎麼被滿足?注意 E40:今天 main 沒有 branch protection、token 是 `repo` scope、CI 是同一個 repo 的 tracked file、最近 60 個 first-parent commit 有 28 個是 direct push。**如果你的答案裡有任何一句是 convention 而非 enforcement,請自己標出來。** + +**Q8** capture 怎麼辦?E13 顯示 `capture_inbox` 是唯一有持續跨行程流量的面(35 cron + 8 Claude);E38 顯示替代方案(Telegram plugin, `getUpdates` polling, 不需 socket)已裝好;E39 顯示它現在壞著(130+ token conflict)且**從未做過 untrusted-input 審查**。你的設計在什麼順序下動它? + +**Q9** 發佈路徑的具體形狀:從 owner 在 Vault 設 `status: ready`(E29)到公開站上出現,中間每一步是什麼?lock 4 的 receipt(E28 今天不存在)由什麼寫回?lock 6 的 admin-only 由什麼滿足? + +**Q10** 你的設計**無法**回答哪些問題?至少要點名「這個月我在做什麼」(E53)並說明你為什麼接受這個代價,或你用什麼**不需要持續分類勞動**的方式回答它。 + +**Q11(前提曝露,必答,兩份設計並排比)** +(a) 如果 `SELECT actor, entity_type, count(*), min(created_at), max(created_at) FROM activity_events GROUP BY 1,2` 顯示 **owner 每天在讀 admin**,你的設計哪一部分立刻錯?怎麼改? +(b) 如果 `go-health-scan` 改推 digest 跑滿一週、三個紅燈**沒有**關掉,你的設計哪一部分立刻錯?怎麼改? + +--- + +## 7. 仍然開放的東西(不要替 owner 決定,但要說明你的設計在每個分支下怎麼變) + +### 四個 owner 決策(D) + +- **D1 · koopa 到底存不存在,還是收斂成只有 publication?** + 收斂到 publication 在 locks 之內(lock 4 讓 koopa 當 snapshot 的接收方)。**刪掉整個服務會重新打開 lock 4**:snapshot 變成從 Vault 出來的靜態 build,「Koopa 收到 snapshot」變成「build 讀一個釘住的 blob SHA」。留著的代價:Postgres + Go + Angular SSR + VPS 去服務 4 份文件、7 個唯讀 GET,外加 E44 的 trigger 手術。刪掉的代價:發佈需要一台有 git push 憑證的機器(不能再從手機瀏覽器經 admin OAuth 發佈)、線上文章改一個 typo 從幾秒變成一次 CI build、lock 6 的「admin-only」要被重讀成「他 merge 那個 publish PR」。 + +- **D2 · 他自己的 todo 住在一個 tracked file 裡嗎?** + 這一個答案決定 MCP 的數量,而且沒有任何一隊能從程式碼決定它。`brief` 的 todo section 呼叫 `OverdueTodoItems`(`internal/todo/query.sql:9-26`),**沒有 `created_by` 過濾**;而 `list_todos` 是 caller-scoped(`TodosByCreator`)。所以 `brief` 是整個 MCP 面上**唯一 owner-scoped 的讀**,而它餵的正是他 2026-07-25 明確保留的 Telegram push。移到檔案:MCP 可以是 0,push 讀 `cat todos.md` + `gh pr list`,最後一個支撐資料庫的讀消失。留在 Postgres:整個資料庫為了一個 query 存活。檔案這邊的反向代價是真的:「這件事屬於哪個 repo」本身就是一次分類,等於 O1 的稅換個地方收;而一個沒有東西逼你清空的 inbox,正是 E14 那 36 個檔案的重生。 + +- **D3 · 那 4 篇未發佈的 `status: ready` 文章 —— 是摩擦,還是編輯決定?** + 若是摩擦,Branch A 的 Vault→PR→merge pipeline 第一次跑就把它們送出去,設計當場被驗證。若他是刻意把那個 3 篇的 RSM 系列擱置,那發佈路徑從來不是瓶頸,Branch A 就是在拆一個活的、能動的系統去修一個不存在的問題 —— 把編輯決定誤讀成摩擦。唯讀情況下這兩者不可區分。 + +- **D4 · `internal/feed`(RSS reader)—— 當成輸入習慣留著,還是丟?** + ingest 確實是自動的(E47 的 ticker),不違反「無法自動化就不要」。但 `curate`/`ignore` 是手動 admin 動作,而 `Curate` 的作用是把 feed entry 連到一個 content row —— 那是他 2026-07-25 移除的站內內容起源路徑。留著 = 2,130 LOC、4 張表、8 個 route、2 個 admin 畫面換一個閱讀習慣;丟掉會一併帶走 `brief` 的 rss section 與 `today.RSSHighlights`。 + +### 三個 UNKNOWN(其中一個會在拆除時永久消失) + +- **U1(最危險)** `SELECT actor, entity_type, count(*), min(created_at), max(created_at) FROM activity_events GROUP BY 1,2` —— **從未執行**,且第一次 `docker compose down -v` 之後永久不可得,而且是**靜默**消失。所有刪除都建立在「他不用了」,而整個量測語料是 agent 端的(MCP 呼叫數、route 定義、cron 紀錄、git history)。有一個代理指標指向同一方向(E52:206 天 2 個完成 todo、8 個 active day),但 `review_period` 過濾 `actor='human'` 且計的是**完成**不是**閱讀** —— 一個他每天早上開、但從不寫入的 console,產生的數字正好就是觀測到的樣子。**沒有任何表記錄閱讀行為。**在動任何東西之前,對本機 `koopa_postgres_data` volume 與 VPS 生產庫**各跑一次並存檔**。 +- **U2** delivery 是不是那個綁定約束 —— `go-health-scan` → digest,一週。**注意這不是一個欄位的改動**:裸 `deliver: telegram` 會每次重推同樣三個紅燈,正是他自己稽核抓到的「懸置條件每次跑都重推」缺陷(§2.4 條 ②③),那會測到「洗版有沒有用」而不是「觸達有沒有用」,還會燒掉他僅剩的通道。必須從第一天就帶 fingerprint 檔(昨天的紅燈集合)+ 年齡門檻(集合改變,或 age 跨過 1/3/7/14/30 天才送)。約 15 行。 +- **U3** 那 4 篇 `status: ready` 是摩擦還是編輯決定(= D3)。一個問題就能問到。 + +**另有兩件在任何拆除之前必須先做的事(不做會損失資料或觸發假警報)**: +1. **把 4 筆 `contents` 全部匯出成 markdown 並 commit 進 `~/obsidian/Writing/articles/`。**E30:`bounded-autonomy-personal-agent-os`(公開語料的 65%、`/hire` 的 #1 Receipt)**只活在 DB**。一個從 `Writing/articles/` 取材的 build 會產出 3 篇的站,並毀掉他的旗艦文。四筆都要查,不是只查那一筆。 +2. **`~/.hermes/desired-state.yaml::koopa0_required_tools` 是手寫的、驅動健康 cron。**留著過期工具名會**原樣重現** E16 的假警報。刪工具時要連那份清單一起刪,不是只刪工具。 + +--- + +## 8. 什麼是壞答案(這一節不是禮貌用語) + +以下每一項都會讓你的設計在收斂會議上被丟掉: + +1. **加一套 taxonomy。**沒有任何 taxonomy 被鎖定,這不是邀請你發明更好的那一套。E53:六個候選模型全部在自己的 schema 裡重新長出一個 container。 +2. **加一個 dashboard。**他已經不開 admin 了。再做一個要他去開的東西,就是把 E4 的失敗重演一次。 +3. **加一個 scheduler。**已經有 15 個 cron 在跑並且能動。 +4. **提出一個 framework**、一套 orchestration 抽象、或任何需要新詞彙表的東西。O10 明文禁止退化成通用 workflow engine。 +5. **推薦超過證據支持的系統量。**E51:固定 scaffold 換模型,22%→33%→49%;已測得的 harness 工程價值 +4.7~+7.7 分。每多一個零件,請說明它憑哪一條 VERIFIED 證據存在。 +6. **同意 §5.1 的結論而不測試它。**兩位獨立設計者的全部價值就在這裡。 +7. **同意 BROKEN 然後照抄 Branch A 的刪除清單。**那是一張工單,不是一份設計。 +8. **把「repo predicate loop」寫成刪除後的剩餘物。**`grep -rn "exec.Command" --include=*.go internal cmd | grep -v _test` → 0(VERIFIED)。那是全新建造。新建的東西要自證,不能因為挨著刪除清單就繼承正當性。 +9. **把 convention 寫成 property。**E40。如果你寫「agent 不能改 verifier」而沒有指出這在今天是慣例不是強制,那一句就是假的。 +10. **在測量日文。**2026-07-25 的裁決是 CONSTRAINT。被測的可以是 `phrase_dataset.dart` 這個 tracked source file(那是軟體工作),**日文本身不被測量** —— 閱讀、紙本教科書、學習時數、頁數、streak,一律不進入系統的任何形式,包含推播、log 與報告。 +11. **在 U1 / U3 未解的情況下,把一個還活著、還在運作的東西當成死的來刪。**所有「他不用了」的量測都是 agent 端的(E12);U1 從未被執行,且拆除之後永久消失。convergent over expansionary 是關於**證據支持多少**,不是關於刪得多爽。如果某個東西的「死亡」只有 agent 端證據支持,**說出來**;如果你的結論是「留得比 Branch B 還多」,只要釘在證據上,那就是合法且有價值的答案。 + +**最有價值的一句話是「你需要的比這個更少」。**如果你的結論是「刪掉更多、蓋得更少」,那不是敷衍,那是這份工作最可能正確的形狀。反過來,如果你真心認為某個東西必須被蓋出來,就把它釘在一條可複核的證據上,並說明什麼證據會讓你放棄它。 + +--- + +## 9. 輸出格式(固定編號、固定順序,兩份設計要能並排讀) + +請完全照這個順序與編號輸出。不要加前言,不要客套。 + +**0. 分支聲明** —— 你對 D1 / D2 / D3 / D4 各假設哪一個分支,一行一條。 + +**1. 設計(≤ 500 字)** —— 一段話說清楚這個系統是什麼、由哪幾個既有零件組成、以及**它與現況最大的一處差異**。 + +**1.5 第一步** —— 在任何破壞性動作之前先跑什麼、跑多久、什麼結果會讓你放棄這份設計。(U2 是整個論點最便宜的證偽:`go-health-scan` → digest,帶 fingerprint + age gate,一週。)如果你的設計不先測試自己的核心前提,說明為什麼不需要。 + +**2. 確切的表與檔案** —— 若有 Postgres:逐表逐欄,每張表附「誰讀 / 多久 / 不存在會怎樣」。若無:逐項列出現行 17 張表的職責由什麼承接。若有檔案契約:給出實際路徑與實際格式(不是「一個 YAML」,是那個 YAML 長什麼樣)。 + +**3. Agent 面** —— 工具數(0 是合法答案)、每個的名稱與 writability、agent 能讀什麼、能寫什麼、**絕不能寫什麼**,以及這些邊界哪些是 enforcement、哪些只是 convention。 + +**4. Owner 面** —— 他會在哪裡看到東西、看到什麼、多久一次。每一則主動推播逐條對照 §2.4 的四個條件。明講「什麼都沒發生的那天」送什麼、以及 checker 壞掉時送什麼。 + +**5. Kotonoha 的端到端走一遍** —— 用真實的那筆工作,五個階段各給具體指令或檔案內容: + (a) **建立**:那一行去哪裡、由誰寫、他怎麼知道寫成功了; + (b) **predicate**:完整寫出來。**它必須擋得住這個攻擊**:`grep -c "Phrase(" == 80` 可以被 45 行複製貼上的合法內容滿足(E24:目前 35 條中只有 4 條含 は/を/へ,而那筆 todo 真正說的就是 は/を/へ 優先,今天不被任何東西測量); + (c) **執行**:agent 做什麼、在哪個 branch、誰跑 predicate; + (d) **關閉**:誰讓它從「未完成」變成「完成」、那個動作花他幾個按鍵; + (e) **狀態住在哪裡**:完成後,「這件事做完了」這個事實由哪些位元組承載。 + 並明講**日文本身在這條流程裡完全沒有被測量**在哪裡體現。 + +**6. 它不做什麼** —— 明確列舉。至少要涵蓋「這個月我在做什麼」,以及任何你刻意不回答的問題。 + +**7. 對抗性自我批判** —— 至少 5 條,每一條寫成「如果 X 為真,我這份設計的 Y 部分立刻錯」。必須包含 §6 Q11(a)(b) 兩題的答案。**這一節寫得軟,整份設計就會被當成沒被自己審過。** + +**8. 你要求 owner 回答的問題** —— 最多 3 個,每個附「為什麼這個問題非他不可、以及不同答案怎麼改變你的設計」。 + +**9. 你想推翻的證據** —— 你複核過、認為 §4 哪幾條不成立或被誤讀。空的也可以,但空的比非空的更需要理由。 + +--- + +## 10. RED-TEAM 問題集(這組問題會被拿去問每一份設計,包含本文提出的那兩個分支) + +拿去自己先過一遍。答不出來的地方,直接寫在第 7 節。 + +1. **你設計的核心機制,哪一條指令能證偽它?**寫出那條指令。如果沒有這樣一條指令,你的機制是信念不是設計。 +2. **什麼都沒發生的那一天,系統送出什麼?那和一個壞掉的 checker 有什麼不同?**(E4:3 次寫入失敗仍記 `ok`。E5:46/50 是 `llm_no_output=1` 而全部 `ok`。) +3. **誰關閉 loop?不關閉的後果是什麼?**(E6:29 次巡檢、32 天紅燈、一句寫給沒有人的交接。E12:`resolve_todo` 一生被呼叫 1 次。) +4. **你的哪些陳述是 convention 而非 enforcement?**逐條標。(E40。) +5. **這個設計在哪一天開始需要 owner 做分類勞動?**他已經放棄過一個需要手動記錄的系統。 +6. **你的證據通道會不會給出 false green?**(vault mtime 會在一個 **agent** 改了 117 個檔的日子回報「日文活動」。一個會撒謊的證據通道比沒有證據更糟。) +7. **哪一個零件是新蓋的,它憑哪一條 VERIFIED 證據存在?**逐個回答。(E51。) +8. **如果 owner 三週沒碰這個系統,回來時它是什麼狀態?**它會自己累積出一個需要被清理的 backlog 嗎?(E14:36 個 pending 檔、47 個 task、2 次執行。) +9. **這份設計裡最貴的一個零件是什麼?把它刪掉,還剩什麼不能做?**如果答案是「沒什麼」,就刪掉它。 +10. **哪一步是不可回復的?你有沒有把它排在前面?**(E30:那篇文章只活在 DB。U1:histogram 在拆除後永久消失。) \ No newline at end of file diff --git a/docs/explorations/six-model-exploration-2026-07-16.md b/docs/explorations/six-model-exploration-2026-07-16.md new file mode 100644 index 000000000..d048174a8 --- /dev/null +++ b/docs/explorations/six-model-exploration-2026-07-16.md @@ -0,0 +1,431 @@ +> **Recovered artifact, 2026-07-25.** This report was produced on 2026-07-16 by a +> 32-agent blind exploration and written only to an ephemeral session scratchpad; +> it was never committed and the scratchpad is gone. It was recovered from the +> Claude Code file-history store and is reproduced below verbatim. +> +> **It has never been ruled on by the owner.** Its central finding — that +> zero-taxonomy collapses in all six models — is the input to that ruling, not +> the ruling itself. Read §5 and §6; §6 is three questions only the owner can +> answer. The report's own bias disclosure is at the end and is load-bearing: +> the O10 axis carried a `lethal = true` prior, so the "zero survived" verdict +> rests on the collapse axis (no prior, 6/6), not on O10. +> +> Every claim in it about the current codebase is marked by its own author as +> ASSUMED — read from documents, not from running code. + +--- + +# 六模型比較報告 —— 幫你選 + +## 1. 一句話結論 + +**5 個 materially distinct(1 個塌陷),但 0 個活下來 —— 而 5 次獨立的 kill 全部收斂到同一個處方:不要採用任何一個模型,把 4 條在每一刀下都沒斷的 invariant 留下,其餘丟掉。**(verified:塌陷軸 6/6 lethal;5 個模型的 kill 各自在 `modelSurvives` 裡獨立寫出「保留容器、移植 invariant」) + +更硬的一句:**這 6 個模型全部改善「誠實」,沒有一個改善「他會不會打開它」——而後者是你唯一的真問題。** 5 個模型的 worstWeakness 是同一句話的六種寫法:誠實、完整、沒人打開。(verified:material-difference verdict 逐字確認「其餘四個的死法全是同一個形狀」,grant-graph 是唯一例外) + +--- + +## 2. 比較矩陣 + +| 模型 | primitive | 結構從哪來 | O1×O10 怎麼化解 | 拒絕表示 | 最可能怎麼死 | +|---|---|---|---|---|---| +| **帳與影**<br>event-ledger | 已簽章的 event(15 個 kind,全是動詞) | fold 出來的連通分量 | kind 問「你剛做了什麼」(自明),不問「這是什麼類」;讀取端焊死成 8 個 view | 未來 · 可寫 status · 容器 · 指派 · 風險 · 刪除 | link 枯竭 → 8 個 view 全塌成時間流。**死時看起來很健康** | +| **相欠簿**<br>promise-network | 承諾 (obligor, promisee, claim, horizon) | 承諾網(4 種 authority 邊) | 捕捉 = 最弱的那種承諾(horizon=null);帳本沒有 executor | 順序 · 無主之事 · status 寫入 · 嘗試 · 風險 | 「未清償無期承諾:247」變壁紙 | +| **話與痕**<br>prose-native | 話(owner 的散文)+ 痕(機器的事實) | **不存在**(話與話之間永遠沒有邊) | 音量(時鐘函數)佔死 status/priority 的槽;散文只有人能寫 | 狀態 · 完成 · 到期 · 指派 · 機器的一句話 | 不衰減的紅堆 → 愧疚牆 → 他停止打開 | +| **兩本帳**<br>effect-reconciliation | 條目(世界狀態句 × 未然/已然) | append 的副產品(4 種時間邊,無 parent) | 捕捉物不在任何地方等待 → 沒有 queue → 沒有引擎要驅動 | 完成 · 步驟 · status · 容器 · 進度 | owner 懶得把意圖寫成可觀察狀態句 → 變成更難用的 Obsidian | +| **簽字才存在**<br>grant-graph | grant(unsigned 空殼 / signed) | authority graph(只有 attenuation 一種邊) | 問題從「這是什麼類」換成「**我要不要為它付**」 | 無授權來源的工作 · 指派 · DAG · 風險 · undo | 對自己簽字很蠢 → **退化成一層仍在正常運作的授權層**(唯一非紅堆死法) | +| ~~斷言與落差~~<br>declarative-solver | 斷言 + 效果紀錄 | 求解痕跡 | 只有一個動詞;評分函數敵視 workflow | 步驟 · 工作本身 · 手動 status · 散文 | **已塌陷 → 見 §4** | + +### 5 個為什麼真的不同(不是換皮) + +只有兩個問題在區辨它們,其餘都是修辭: + +| | 捕捉變成什麼 | 誰能讓事情為真 | +|---|---|---| +| **帳與影** | `noted` — 零負債,在 warm 裡淡出 | lineage-disjoint 的人簽 accepted | +| **相欠簿** | 一筆債 — 進「247」 | witness 押信用 attest | +| **話與痕** | 一句話 — 靠時鐘沉下去 | 收(一筆機器的痕) | +| **兩本帳** | 空效果的未然 — 系統零義務 | 只有不相交的獨立觀測,且會自動失效 | +| **簽字才存在** | 圖外的未簽空殼 — 會過期 | **沒有人**(producer 只能說「我花完了」) | + +決定性的區辨情境(verified,出自 material-difference 裁決):**對外承諾發布 X、從未發布、然後撤回** —— 相欠簿判 broken(promisee=外部,issue-time 就沒有 cancelled),帳與影判乾淨的 revoked(read-time 才問效果落沒落)。同一段歷史,兩個判決。這是這對表親不塌陷的硬證據。 + +--- + +## 3. 五個模型逐個驗屍 + +> 三軸 = O10(會不會退化成 generic engine)· 塌陷(會不會長回 PARA)· locked outcome 違反。 + +### 3.1 帳與影(event-ledger) + +**是什麼**:一張 append-only 的簽章事件表 + blob。15 個 kind 全是動詞。status、thread、進度全部是 fold 的輸出,schema 裡沒有位置可寫。 + +**靈魂**:分類法問你一個你答不出的問題(這是什麼類);event 問你一個自明的問題(你剛做了什麼)。O1 的零成本不是省欄位省來的,是換問題買來的。 + +**天然強**(verified,stress 4 PASS 全在這):**O5 / O6 / O9 幾乎免費,而且是 primitive 送的不是設計出來的。** `revoked-but-landed` 是一等終態,因為沒有可寫的 status 就沒有一格可以拿來說謊。lineage 非 root 節點集合不相交 ——「你不能生一個小弟來蓋自己的章,因為小弟的血統裡有你」。 + +**代價**:沒有「手動修正」這個動作;每個新問句是一次 PR;200 筆孤兒 = 一條時間軸 + FTS。 + +**三軸打進去哪裡(3/3 lethal)**: +- **O10**:`accepted` 的唯一謂詞是 lineage 不相交,而 **root 對任何 target 恆滿足它**。週日 23:40,六筆 root-signed `claimed` + `accepted`,六條線變 done,`dishonest` view 空的。整套 O6 一次都沒問過「owner 能不能自我驗收」——它蓋了一座對著空地的碉堡。而且 `by_when` 已經在 schema 裡、executor 已經被 O2 指定(cron)、status 已經可寫(附操作手冊)。**generic engine 不是它的退化風險,是它的出貨形態,只是被拆散在 repo 邊界兩側。** +- **塌陷**:8 個 view 每一個都是缺席偵測器(無後代/無 observed/無 accepted)。「健康、進行中的工作」在 8 個 view 裡出現 **0 次**。你越在做一件事,系統越看不見它。 +- **O3**:`dispatched` 是過去式記錄,系統「從不執行任何東西」⇒ **配額不可能攔截**;更致命的是 agent 可以 dispatch 而不 append,**配額的分母是被審計者自己填的**。「信任的表面積等於那份清單」為假。 + +**活沒活**:沒有。但它的 lineage-disjoint accept 與 (bytes, renderer) hash 對是真資產(見 §5)。 + +### 3.2 相欠簿(promise-network) + +**是什麼**:一種實體(承諾)、四種 authority 邊、零個可寫狀態欄位。捕捉是承諾的退化態。 + +**靈魂**:帳本記得誰欠誰,但帳本不能執行。系統不是任何承諾的 obligor,所以系統什麼都不做。 + +**天然強**(verified):**O4 / O10 / O5**。承諾是自我約束 ⇒ 只有 obligor 能改自己的承諾 ⇒「agent 改期 owner 的承諾」不是被拒絕,是不在動作集合裡。issue-time mandate bound:越界的承諾**從來沒有存在過**,所以早上不需要審 20 個 diff,只要問「我昨天寫的 bound 對嗎」。 + +**代價 + 一個設計者沒發現的死結**:stress 獨立推出 **Root A** —— 單一 owner ⇒ mandate 樹根 = owner ⇒ descendants(owner) = 全體 agent ⇒ **對 obligor=owner 的承諾,合法 attester 是空集合。owner 的承諾永遠無法 kept。** 清償佇列沒有「做完」這個出口;rebind 只把 unbounded 轉成永久 overdue。**繳了結帳稅也沒有出口。** + +**三軸(3/3 lethal)**: +- **O10**:bound 住在 claim 裡,而 claim 明文「不被解析」。分岔無第三條路:真的不解析 → mandate gate 不存在 → **O3 死**;解析 → **系統內有一個 programmable write-time guard** → O10 的「零個可程式化狀態轉換」是假的。而且模型自己 S6 的等價類 mandate 已經是 data-dependent bound,於是 `僅當 kept(A) 才可 issue B` 是最簡單的一種 → **depends_on**。防線守錯了門:他盯著 excuse,scheduler 從 mandate 走進來。 +- **塌陷**:`issuing_authority` **就是** `parent_id`,`lineage()` 是官方的資料夾列舉 API。而 umbrella 進 `unbounded` 計數 ⇒ **你越組織,債越多**。 +- **O8**:claim 是自由文字 + O3 要求 agent 也是 obligor ⇒ hermes(職責就是讀 diary)可以把 diary 衍生物寫成一筆合法承諾。**append-only + 只有 obligor 能 withdraw ⇒ owner 對那行字在拓撲上無權處置。永久。** + +**活沒活**:沒有。但三條 invariant 可直接移植(見 §5)。 + +### 3.3 話與痕(prose-native) + +**是什麼**:兩種列 —— 人寫的散文、機器寫的凍結事實。加一個不存在於磁碟的東西:音量(時鐘函數)。 + +**靈魂**:話是你說的,痕是機器做的,音量是時間說的。零結構不是「結構還沒來」,是結構的位置被時間佔死。 + +**天然強**(verified):**S10 是全批最強的一格**,而且強在反向論證:generic engine 沒有一個能表示「這句話正在變得聽不見」。加上 **effect transport 是系統 code、agent 碰不到線** —— 這是全批唯一一個把「配額」做成真 enforcement 而非事後計數的模型(對比帳與影 O3 的死因)。 + +**三軸(2/3 lethal —— 唯一躲過 O10 的)**: +- **O10:lethal = false。** 但真正擋住的**不是它列的四道防線**(佔位是修辭;code-vs-config 對 owner=developer 無效;「沒有邊」是儲存宣稱)。真正硬的是它從沒說的兩件事:**條 沒有「完成」**(n 是對世界動手的預算,燒完 ≠ 完成),以及 **hash 綁定只能往後綁 —— 未來的東西沒有 hash,所以前向宣告在物理上不可表示**。DAG 的本質就是前向宣告。**對的結論配錯的證明。** +- **塌陷 lethal**:音量量的是**敘述**不是工作。他埋頭寫三週 code 一個字沒打 → 系統零觀測者 → **他最忙的那條線沉得最快**。被逼出 heartbeat(每月 16 筆零內容的心跳)→ 而 `推進=再說一次` 和 `檢索=字面` **用的是同一批字** → 保命的機制就是埋葬內容的機制。然後為了分組打出 `koopa0 ·` 前綴 = 沒有 rename、沒有 index、typo 即斷鏈的 Area。 +- **O4 lethal**:條 綁 class × n × T,**不綁 payload**。「寄信 n=3」的 agent 能寄 3 封內容任意的信,包括「Koopa 確認週五交付」。commitment 在世界裡被擴張了,n 只決定它最多能破 3 次。而 prevent 的三條路各撞死 O10 / O3 / ZERO TAXONOMY。**S3 宣稱「擋得住,靠不可表示」,costs#8 卻承認「錯誤的成本由次數上限吸收,不由結構吸收」——同一份文件裡兩句不能同時為真。** + +**活沒活**:沒有。但它是唯一在 O10 上真的贏的,代價是輸掉 O4。 + +### 3.4 兩本帳(effect-reconciliation) + +**是什麼**:一種 primitive 兩種時態(未然 / 已然)。系統唯一做的事叫對帳。誠實地承認它有第二個 primitive(效果紀錄),因為 O5 逼的。 + +**靈魂**:它不是加了審計層的 event store,是**把「聲稱事情結束了」這個能力從系統裡拿掉的 event store**。 + +**天然強**(verified,這是全批最乾淨的兩格):**O5 / O9 是型別層的勝利** —— 「已取消」不在 effect 的結果 enum 裡(不是被禁止,是不存在);「離線 → unknown 而非 false」是裁決 enum 含 unknown 且無 unknown→false 寫入路徑的必然。**O7 的 `authorized` 綁「公開處在觀測面 P 上應被觀測到指紋 H」而非綁檔案** —— 這是全批最原創的一步。 + +**三軸(3/3 lethal)**: +- **O10**:O3(agent 必須動)× O7(owner 必須看過成品才簽 H)⇒ **存在一個結構性的、探針再密都補不了的窗口**:H 在 hermes 動手前不存在,所以 H′ 對不了帳。而它拒絕的每一樣東西(status / attempt / retry policy / archive)正好是唯一能停下迴圈的器官。**3/31:31 篇。而系統每一步都完全誠實。** +- **塌陷**:O4 要求 owner 隨時知道 agent 能造成什麼 ⇒ 系統**必須出貨**「列出所有開著的 envelope」這個 view ⇒ **那個畫面逐像素就是 PARA 的 Areas 頁面**。而 187 條生念頭在三軸上全部同值(stress verified:對帳狀態全同、深度全 = 1)。 +- **O8**:「沒有 import path」擋自動拉取,擋不住 agent 把 diary 改寫進 agent-signed 未然 的自由文字。**append-only + 拒絕 archive ⇒ 永久不可移除。** 它把全部力氣花在「一句話算不算數」(authority),零力氣花在「一句話能不能被說出來」(admissibility)—— 而 O8 要的恰好是後者。 + +**活沒活**:沒有。O5 / O7 的兩個構造是全批最值得摘的(見 §5)。 + +### 3.5 簽字才存在(grant-graph) + +**是什麼**:只有一種實體(grant,兩態),只有一種邊(attenuation),加一本 append-only 的 receipt。unsigned 空殼在圖外、零效力、會過期。 + +**靈魂**:**工作不是實體,是 spend 的影子。** 沒有 grant 就沒有節點。 + +**天然強**(verified,全批 9 PASS 最高):**O3 / O4 / O5 / O6 / O9**。no ambient authority(grant **就是** handle,沒有 grant 不是被拒絕,是根本沒有東西可以呼叫);耗盡即冪等(double-spend 不需要一個檢查,它是耗盡條件的自然湧現);path 代數;凍結 snapshot ⇒ Vault 從來不在任何讀路徑上,O9 **無事可做**。 + +**它跟其他四個不在同一個平面上**:它是唯一不死於紅堆的 —— 它退化成一個仍在正常運作的授權層,而且它自己誠實地把這寫進 worstWeakness。 + +**三軸(3/3 lethal)**: +- **O10**:O6 的 path 不相交檢查**正好禁止**單一 agent 一手包辦 produce→verify→act ⇒ 有閘門的過夜鏈只剩四條路:無條件預簽(**acceptance 變純裝飾**)、等驗收再簽(**O3 死**)、寬 grant 內部排序(**被 O6 寫不進去**)、加 `activates_on`(**那是 depends-on 邊 = Airflow 套 IAM 皮**)。**O6 親手拆掉 O10 唯一的逃生口。** +- **塌陷**:attenuation 只約束 child,**root 簽出的 grant 沒有 expiry 上限** ⇒ 一張 designation=`koopa0.dev`、expiry=2099 的 root-child **完全合規** ⇒ **Area,一次簽字,不需要任何壓力**。(獨立測試者誤認這條被擋住了;沒有。)而 nightly 續簽 = re-sign = 新 lineage,圖上只有 attenuation 一種邊 ⇒ **45 張之間沒有任何邊,跨月加總只能 grep 一個 free-text 欄位,漏一晚系統不會告訴你。** +- **O6**:**grant 必須會死(為了擋 Area),agent 不會死。** 每一次續約都替它憑空造出一條與自己過去 disjoint 的 branch ⇒ 昨天的產出,今天用新 grant 合法 accept,ledger 上完全隱形。修好它需要 continuation 邊 = 你剛擋掉的那個 Area。**兩條它自稱最硬的防線互為引信。** + +**活沒活**:沒有。但它 4 條核心 invariant 在 kill 下攻不動(見 §5)。 + +--- + +## 4. 塌陷的那一個 + +**斷言與落差(declarative-solver)→ 塌陷進 兩本帳。**(verified,信心中等偏高,非壓倒性) + +五條承重軸逐一相同:primitive 形狀(一句世界狀態宣稱 + 一個二值的宣稱/觀測標籤)、真值機制(只有獨立探針能給真值、無人能手動標 done、真值隨重新觀測自動失效)、拒絕清單(步驟/依賴/status/assignee/優先級/容器/進度/retry/cancelled **逐條重疊**)、產品(落差)、死法(探針稅 → 紅堆失去可信度 → 繞回 Obsidian,**連措辭都撞**)。 + +兩處殘留不構成第二個想法:**effect record 是它自我稽核後把兩本帳 S5 的洞補起來的補丁**(它自己說「硬把效果塞成斷言的欄位,S5 就會說謊」——被批評的正是兩本帳);**solver 樹是全篇唯一未被機制化的器官**(誰在解?怎麼解?),而且「要 A 為真需要 B 為真」正是它自己拒絕清單上的「依賴」。 + +翻盤條件只有一個:若 solver 真能機制化地把落差劈成衍生斷言長成樹,那就是材質差異。骨架沒交代,我不能替它補。 + +**它自己的死因值得單獨記下(對現況直接有用)**:判準稅逼出 **marker-probe 梯度** —— owner 寫 `git tag done/X` 的 5 行 probe,跨 lineage ✓、封存先於工作 ✓、系統跑 ✓、證據可點開 ✓。**四道鎖全過,沒有偽造任何東西,事情真的做完了。** 系統當場變成一個比 Linear 差的 todo list —— **由一個完全遵守它全部規則的 owner 優化出來的**。它宣稱「沒有讓它退化的梯度」;梯度是它自己的機制切出來的。 + +--- + +## 5. 橫貫發現 + +### (1) 塌陷是唯一的、無例外的結局 —— zero-taxonomy 站不住 + +**6 個模型、6 個不同的 primitive、6 次獨立的 kill,全部 lethal。**(verified)而且四個模型的容器**已經在自己的 schema 裡而沒發現**: + +- 相欠簿的 `issuing_authority` = `parent_id`,`lineage()` 是官方 API +- 兩本帳的 envelope 列表 query = PARA 的 Areas 頁面,**而 O4 強迫它出貨這個 view** +- 簽字才存在的 root-child + far-future expiry = Area,**一次合規簽字** +- 斷言與落差的**讀取面** = owner 親手宣告、系統據以執法(O8 的鎖就靠它)、先於任何求解存在的結構 = Area 本人 + +壓力永遠是同一個問題:**「這個月我在忙什麼?」** 每個模型都答得出「什麼效果落地了」,**沒有一個答得出「我在做什麼」**。 + +**結論(這是這份報告最值錢的一句):zero-taxonomy 不是「移除了分類法」,是「移除了可維護的分類法」。** 成本沒有下降,它從 schema 搬到你的手指,然後拆掉了 rename、index、merge、cascade、成員編輯。你得到的是 PARA —— 手工的、無索引的、會腐爛的、系統看得見卻管不到的 PARA。**這比誠實承認需要容器嚴格更差**,而任務自己就是這樣定義失敗的。 + +### (2) O1×O10 的共同結構:六個模型都只買到 O1 的前半 + +每個模型都用**同一個交易**化解這對張力:**捕捉的零成本,是用「捕捉物不進入任何結構」買來的。而不進入結構 = 檢索無軸 = 推進不可達。** + +O1 的字面是「快速記下**並推進**」。六個模型 6/6 只買到「記下」。(verified —— 這是六條獨立的 stress 判定,含全批唯一一個 FAIL) + +- 簽字才存在 **S12 = FAIL**:「authority graph 在那 200 筆上完全沒有結構」 +- 兩本帳:三軸在 190 筆生念頭上全部是常數 +- 相欠簿:「該推進的那筆會逾期在你臉上」是循環的 —— unbounded 依定義永不 overdue +- 帳與影 / 話與痕 / 斷言與落差:字面 FTS + 時間,設計者全部自承 + +而斷言與落差的 kill 把它推到最尖:**第 200 筆才發明的前綴方案不會回溯標記 1–199 ⇒ 要讓語料可導航,你必須在第 1 筆就發明分類法 = 先懂分類法。O1 被機制性違反,不是被課稅。** + +### (3) 拒絕表示 ≠ 消滅 —— 器官只是搬到看不見的地方 + +六個 O10 kill 的共同形狀:**engine 的器官不會因為你不表示它而消失,只會因為你不表示它而不受監督。** + +- 帳與影:executor 搬進 `~/.hermes` 的 cron,read-side config 搬進 agent 的 prompt +- 相欠簿:scheduler 搬進 mandate 的 bound(一段宣稱不被解析卻必須被機器求值的自由文字 = 嵌入式 DSL) +- 兩本帳:control flow 搬進 agent 的散文 —— **ledger 唯一看不見的地方** +- 簽字才存在:ordering 搬進「簽字的時機」 +- 斷言與落差:status 搬進 `git tag` +- 話與痕:分組搬進句子的前綴 + +**而不受監督正是這些模型唯一真正禁止的東西。** + +### (4) 誠實的邊界永遠停在系統邊界上 + +四個模型(帳與影 O3、相欠簿 O8、兩本帳 O8、斷言與落差 O7)死在同一件事:**ledger 只對它被告知的事誠實。** 它們在 ledger 內是法庭級誠實的;在 ledger 與現實之間,它是一本由被審計者自己填寫的帳。 + +唯一的例外是**話與痕**:**transport 是系統的,agent 不碰線。** 這一條是全批唯一把「配額」做成真 enforcement 的機制,也是所有 issue-time bound 能不能成立的前提。 + +### (5) 五個模型死於同一句話,而那是你唯一的真問題 + +worstWeakness 逐一:「有 O5/O6/O7 級誠實保證的、空的、沒人打開的 log」/「247 變壁紙」/「永遠紅著的愧疚牆,他停止打開它」/「變成一個更難用的 Obsidian」/「false 集合被殭屍污染,他學會忽略 false」。 + +**你的記憶索引已經記著這件事**:`koopa0.dev emptiness = deferral —— empty DB 是一個被記錄下來的被動模式 deferral,不是故障;fix 是 exposed-act-first,不是更多系統`(assumed —— 我讀的是記憶索引摘要,不是原檔)。 + +**這 6 個模型,沒有一個處理這個問題。它們全部是「更多系統」。** + +### (6) 值得留下的:4 條 invariant —— 而且大多已經在跑 + +5 個 kill 的 `modelSurvives` 獨立收斂到同一份清單。它們的共同性質是:**與容器完全正交**。 + +| invariant | 幾個模型獨立導出 | 現況狀態 | +|---|---|---| +| **acceptance 需要 producing lineage 產不出來的簽章**(「小弟的血統裡有你」) | 3(帳與影 / 相欠簿 / 簽字才存在) | content 已滿足(Koopa 是唯一 publisher);todo 是問號 —— 見 §6 | +| **授權綁 (source bytes, renderer) 的 hash 對,不綁「文章」** ⇒ 失效是減法算出來的,不需要 invalidation code | 4(帳與影 / 兩本帳 / 簽字才存在 / 斷言與落差) | **見下方 O7 衝突** | +| **cancel 不吃掉 landed effect · timeout ≠ failed · unknown 永不自動轉 false · retry 是新的一筆不是計數器** | **5(全批 stress 5×PASS —— 最穩的一條)** | assumed:現況無外部效果 ledger | +| **issue-time bound(事前列舉),且 transport 必須是系統的** | 3(相欠簿 / 簽字才存在 / 兩本帳)+ 話與痕給了唯一正確的執行條件 | assumed:Option B「存取邊界是 MCP transport」已經是同一個想法的弱版本 | + +**而三顆這些模型最自豪的牙,你已經在跑**(assumed —— 讀自 CLAUDE.md 文字,非跑過的 code):`propose_content` 進 review queue(agent 絕不 publish)· `set_todo_recurrence` compute-on-read 無 scheduler · `propose_*` inert draft + 逐項 activate。 + +**所以正確的框架不是「把 4 條 invariant 裝到一個穩定系統上」**,而是:**這 4 條是你 2026-07-15 semantic reset 的耐用核心 —— 大多已經鎖定或已經在跑,delta 比任何模型宣稱的都小。**(assumed:記憶索引說該 reset 已鎖 Vault=authoring truth (snapshot+blob SHA+receipt)、NO search/embedding、diary isolation,且「cold audit recommended PR-5 pause, owner pending」;CLAUDE.md 可能是 reset 前的版本。這需要用 code 驗,不是用文件驗。) + +**O7 的一個必須攤開的衝突(兩個來源互相拉扯,我不替你收)**: +- (a) 帳與影 stress S6(verified):**runtime SSR 讓 (source_hash, renderer_hash) 機制失效** —— 公開語意在每個請求時產生,沒有 publish event,output_hash 是哪一次請求的?漂移偵測只在 build-time 靜態站成立。 +- (b) 記憶索引(assumed):reset **已經鎖了** snapshot + blob SHA + receipt = 凍結位元組。 + +要嘛 serving path 已經是 frozen bytes(那全批最收斂的那條 invariant 乾淨移植),要嘛 SSR-serving 與 reset lock 直接衝突(那是一個真的 open conflict,不是我能替你關的)。**這一格請用 code 驗,別用文件驗。** + +--- + +## 6. NEEDS-OWNER + +只有三個,而且第一個可能讓其餘全部歸零。 + +### Q1 —— 你的工作分佈:self-heavy 還是 agent-heavy? + +**這是唯一能把整份建議清零的問題,而且只有你知道答案。** + +簽字才存在自己把裁決點交還給你,逐字:「它在『有他者參與』的區域是我見過最硬的骨架;在『只有 owner 自己』的區域是一層薄漆。**這個分佈不是我能從 O1..O10 導出的,硬猜就是我在編。**」 + +上面 4 條 invariant 的價值,**全部**隨「有第二方或有外部效果」的工作比例縮放。若你的工作實際上大多是:你自己、一個人、沒有 agent、沒有 publish —— **那 4 條全部是穿著授權外衣的儀式,答案是一條都不要做。** + +而且它是**可測的**(這是這份報告唯一的可執行檢驗):數一數 `activity_events` 裡 `actor != 'human'` 的比例,以及走到 `published` 的 content 數。低 → 這整個 6 模型練習的產出是一句「你不需要這個」。 + +### Q2 —— 你真的需要「過夜、多步、中途要驗收」的 agent 工作嗎? + +**O3 × O6 × O10 在這個情境上三向不相容**(verified —— 兩個獨立的 kill 在兩個不同模型上撞出同一個 trilemma)。 + +- **不需要** → trilemma 從不觸發,三條全部共存,`propose_*` + 逐項 activate 就夠了(你已經在跑)。 +- **需要** → 挑一隻角,沒有第四條路: + - 無條件預簽 → **acceptance 變裝飾**(O6 只剩記錄,閘不住任何事) + - 等驗收再簽 → **O3 死**(你在睡覺 = 逐項批准) + - 加條件式啟用(`activates_on: receipt`)→ **O10 死**(那就是 depends-on 邊) + +**這不是工程問題,是「你要不要這個能力」的問題。** 若答案是不要,你剛剛省下整個 O10 的煩惱。 + +### Q3 —— 捕捉之後三個月,你需不需要靠系統找回那一筆? + +**O1(推進) × NO-search/embedding × zero-taxonomy 三者互斥。**(verified:6/6 模型獨立撞牆,含全批唯一 FAIL) + +三個 lock 只能留兩個: + +- **放棄 O1 的後半** —— 承認捕捉的價值只在清空腦袋的那一刻,找不回的就該死。**內部一致、殘酷、而且是最 convergent 的答案。** 代價:偶爾真的弄丟一件重要的事。 +- **放回 search/embedding** —— 翻掉 2026-07-15 的 lock。(注意:斷言與落差的分析指出**還有第三隻角**是設計者沒看到的 —— 啞的捕捉設 TTL:不在捕捉當下拒絕你,所以不違反 O1 的兩秒;代價是把顯性的拒絕換成靜默的丟失。) +- **放回 taxonomy** —— 那就是承認 §5(1) 的結論:容器不是被消滅了,是被降級成無工具版本;不如把它留在 schema 裡,讓它可以 rename。 + +**我不替你決。但要指出:這三個 lock 是你自己在 07-15 同時鎖上的,而六個模型的獨立分析說它們不能同時成立。** + +--- + +### 附:一個 pointer,不是一個 finding + +`resolve_todo` 讓 agent 把**自己建立**的 todo 移到 terminal state(done / archived / dismissed),caller-scoped。這**看起來**像 lineage 自我驗收 —— 但它取決於一個語意問題,而那是你的:**「關掉自己建的 todo」是對一個交付物的 attestation(那 O6 適用),還是對自己的便條紙做私人清理(那 O6 不適用)?** 現況的框架(readback loop 的自清)讀起來像後者。 + +**這是需要用 code 驗、並由你裁決語意的一條線索,不是一個已驗證的缺陷。** 我讀的是 CLAUDE.md 的文字,不是跑過的行為 —— 而行為宣稱不能從 source 驗證。 + + +--- + +# 附錄 A —— Material-difference 裁決全文 + +**5 個真正不同,1 個塌陷(declarative-solver → effect-reconciliation)。** + +## 唯一的塌陷 + +**effect-reconciliation ≡ declarative-solver。** 兩者在五條承重軸上逐一相同:primitive 形狀(一句世界狀態宣稱 + 一個二值的宣稱/觀測標籤)、真值機制(只有獨立探針能給真值、無人能手動標 done、真值隨重新觀測自動失效)、拒絕清單(步驟/依賴/status/assignee/優先級/容器/進度/retry/cancelled 逐條重疊)、產品(落差=相左)、死法(探針稅 → 紅堆失去可信度 → 繞回 Obsidian,連措辭都撞)。 + +declarative-solver 的兩處殘留不構成第二個想法:effect record 是它**自我稽核後把 S5 的洞補起來的補丁**(F 說「硬把效果塞成斷言的欄位,S5 就會說謊」——被批評的正是 D),solver 樹則是全篇唯一未被機制化的器官,而且「要 A 為真需要 B 為真」正是它自己拒絕清單上的「依賴」。我保留 effect-reconciliation 作代表:它的「同一 primitive 兩種時態」講得更乾淨,也對「效果的寫入沒有落腳處」這個洞更誠實地暴露(F 補了,但補的是同一台機器)。 + +## 剩下 5 個的真實分界(每一條都是同一段歷史下的不同判決) + +| | 原子提問 | 誰能讓事情為真 | 捕捉變成什麼 | +|---|---|---|---| +| **event-ledger** | 你剛做了什麼(動詞) | lineage-disjoint 的人簽 accepted | `noted` — 零負債,在 warm 裡淡出 | +| **promise-network** | 誰對誰欠什麼、到期沒 | witness 押信用 attest | 最弱的承諾 — 一筆債,進「247」 | +| **prose-native** | 你想說什麼(散文) | 收(一筆機器的痕) | 一句話 — 靠時鐘沉下去 | +| **effect-reconciliation** | 世界該是什麼樣(狀態句,無動詞) | 只有不相交的獨立觀測,且會自動失效 | 空效果的未然 — 系統對它零義務 | +| **grant-graph** | 你要不要為它付 | 沒有人(producer 只能說「花完了」) | 圖外的未簽空殼 — 會過期 | + +三個決定性的區辨情境: + +1. **凌晨兩點記「維修腳踏車」** — event-ledger 給你一則會淡出的 note(零債);promise-network 給你一筆永不逾期的無期承諾(這正是它的死因);grant-graph 把它擋在圖外、時間到就丟進刻意難用的垃圾堆;effect-reconciliation 收下它但它永遠等 refine。 +2. **對外承諾發布 X、從未發布、然後撤回** — promise-network 判 broken(promisee=外部,issue-time 就沒有 cancelled);event-ledger 判乾淨的 revoked(沒有 dispatched,read-time 才問效果落沒落)。**同一段歷史,兩個判決** —— 這是 A/B 這對表親不塌陷的硬證據。 +3. **owner 簽給 claude 三次額度、claude 一次沒用** — grant-graph:耗盡,帳面乾淨,沒有 broken 這個概念;promise-network:mandate 本身是承諾,違約,有人被歸責。 + +## 兩個值得記下的關係(不是塌陷,但是結構性的) + +- **prose-native = event-ledger 的死亡狀態被當成設計採納。** event-ledger 最怕的「link 枯竭 → 所有 view 塌成一條時間排序的文字流」,正是 prose-native 的出廠設定(「話與話之間永遠沒有邊」+ 音量)。同一個終局,一個是屍體,一個是產品。 +- **grant-graph 是唯一不死於「沒人讀的紅堆」的模型** —— 其餘四個的死法全是同一個形狀(誠實、完整、沒人打開)。grant-graph 退化成一個仍在正常運作的授權層,而它自己已經誠實地把這一點寫進 worstWeakness。這使它在「是不是一個整體模型」這個問題上,跟另外四個不在同一個平面上比較。 + +## 誠實標註 + +- 我判 effect-reconciliation/declarative-solver 塌陷的信心是 **中等偏高,不是壓倒性**。翻盤條件只有一個:若 declarative-solver 的 solver 真的能機制化地把落差劈成衍生斷言,那它在螢幕上會長出 effect-reconciliation 結構上禁止的樹(「永遠長不成 tree」),那就是材質差異。但骨架沒有交代誰在解、怎麼解,我不能替它補。 +- **event-ledger / promise-network 是次接近的一對**(authority spine、attest 機制、fold 架構全部同構),我判不塌陷靠的是兩個具體判決差(捕捉是否生債、撤回性在 issue-time 還是 read-time 決定),不是靠詞彙。若把 promise-network 的 promisee 欄位與 excuse 邊拿掉,它就是 event-ledger 的一個子集。 +- **promise-network / prose-native / declarative-solver 三者的紅堆壁紙化死法幾乎同形**,但堆的成分不同(捕捉本身 / 未收的外部效果 / 啞掉的 false),因此螢幕不同 —— 我用「不同產品行為」這條 disjunct 保住它們,不是用「不同死法」。 + + +## 逐對比較(15 對) + + +### event-ledger ↔ promise-network — 不塌陷 + +全組第二接近的一對,靜止結構高度同構:append-only 已簽章行為 + 封閉邊集合 + 零 status 欄位 + 全部 view 是 read-time fold + 完成需 lineage-disjoint 第三方(accepted ≡ attest)+ issue-time 檢查的 bounded delegation(granted ≡ mandate)。但兩處分歧產生不同螢幕與不同死法。(1) 捕捉語意相反:A 的 `noted` 不是意圖、不進 unanswered、零負債;B 只有一種型別,捕捉必然是「最弱的承諾」,必然進「未清償無期承諾:247」。凌晨兩點記「維修腳踏車」,A 給你一則在 warm 裡會淡出的 note,B 給你一筆債。(2) 可撤回性的判準不同:B 用 issue-time 的 `promisee=外部世界` 決定不可撤回;A 用 read-time 的「效果有沒有 landed」決定。情境:我對外承諾發布 X、從未發布、然後撤回——B 判 broken(promisee 是外部,只有 kept/broken),A 判乾淨的 revoked(沒有 dispatched,不進 dishonest)。同一段歷史,兩個判決。死法也不同:A 死於 link 枯竭(結構塌成文字流,「看起來很健康」),B 死於債務壁紙化。 + + +### event-ledger ↔ prose-native — 不塌陷 + +C 是 A 的死亡狀態被當成設計採納——A 的最壞情況是「link 停止 → 連通分量退化 → 所有 view 塌成一條按時間排序的文字流」,而 C 一開始就宣告「話與話之間永遠沒有邊」,並用 音量(時鐘函數)當唯一排序。這是相反的立場,不是塌陷。(A 的 warm 用 revisited 密度加權 ≈ C 的 音量,這一格確實同構,但它在 A 是 8 個 view 之一,在 C 是全部。)另兩處硬分歧:C 的作者權不對稱(散文只有 owner 能寫、機器在 schema 裡沒有一格可以放一句話)在 A 不存在——A 的 `proposals` view 明確允許 agent 簽 intended/linked;C 的 條 是無記名的,系統在機制上答不出「哪個 agent 燒的」,A 的 granted 帶 lineage。 + + +### event-ledger ↔ effect-reconciliation — 不塌陷 + +原子的提問相反。A 的 15 個 kind 全是動詞(「你剛做了什麼」),A 的整個 O1 論證建立在「動作對行動者永遠自明」;D 明確說「條目沒有動詞…你只能寫世界的狀態句」,而 D 的死法正是「owner 懶得把意圖寫成可觀察的世界狀態句」——A 的 O1 解法恰好是 D 的死因。第二:A 允許人的見證當真值(`accepted`,只要 lineage-disjoint),D 拒絕 done 且「相符」會在世界改變時自動失效。情境:agent 發布 → owner accept → 三個月後頁面 404。A 的 accepted 永遠成立;D 的對帳重新亮紅。第三:A 的 `linked` 是對稱邊、產生可攤開的連通分量(5 則筆記 = 1 個 thread);D 只長 chain(「永遠長不成 tree」),無法把 5 則平行的話收成一件事。 + + +### event-ledger ↔ grant-graph — 不塌陷 + +覆蓋面互補到近乎不相交。E 的未簽空殼在圖外、零可見度、有 shelf life 會過期;A 的 `noted` 在圖上、進 warm、永不過期。E 自己承認「在只有 owner 自己的區域是一層薄漆」。E 唯一的邊是 attenuation(無 linked、無 causes),所以 E 裡「一件事」根本不存在——「工作不是實體,是 spend 的影子」;A 的 thread 是整個模型的中心。E 拒絕 producer 宣告 completion(只能說「花完了」),A 有 claimed/accepted。死法也不同:E 是全組唯一不死於「沒人讀的紅堆」的——它退化成一個仍在正常運作的授權層。 + + +### event-ledger ↔ declarative-solver — 不塌陷 + +真值來源決定覆蓋率,兩者相反。F 的真值只能是 probe 的回答,「沒有人能打字打出 done」;A 允許 lineage-disjoint 的人簽 `accepted`。情境「腳踏車修好了」:A 可以被見證成立;F 永遠是啞的斷言(世界沒有介面能回答),無人能推進。對線下人生的覆蓋率是 100% vs 0%。第二:A 的結構是 owner 手畫的 linked 圖(owner 是唯一畫圖的人);F 的結構是 solver 燒出的求解樹,且 F 明確說「沒有『放進去』這個動詞」、trace 不可定址——owner 在 F 裡不能把任何東西放進結構。 + + +### promise-network ↔ prose-native — 不塌陷 + +相反的兩端。B 的每一列都有 obligor、都會逾期、都會違約;C 的話沒有 obligor、沒有到期,靠 音量 淡出,且到期日「只以字元的形式活在他的句子裡,系統從不解析、從不排序、從不提醒」。決定性的是紅堆的成分:B 的紅堆就是捕捉本身(247 筆無期承諾——捕捉零摩擦正是鼓勵你不清償);C 的捕捉會衰減,紅堆是未收的外部效果 + 矛盾 + 待准的公開候選。不同的堆 → 不同的螢幕 → 不同的死亡起點(雖然兩者最後都是「他停止打開它」)。 + + +### promise-network ↔ effect-reconciliation — 不塌陷 + +完成的判準不同:B 的完成 = witness 押上信用的 attest(本身是網上一筆承諾);D 拒絕 done,最強只能說「到 T 為止,最後一次不相交的獨立觀測與意圖相符」,且會隨世界改變自動失效。B 強制 obligor(「沒有無主之物」,每一列都有人欠);D 明確拒絕 assignee(「只有誰簽了這個意圖、誰觀測的」)。B 有 excuse 邊做事後責任重分配(A 沒兌現則 B 免責);D 明確拒絕依賴。情境:agent 沒交東西導致我沒交——B 顯示 broken-but-excused,D 只能顯示兩條各自的落差,無法表示條件式歸責。 + + +### promise-network ↔ grant-graph — 不塌陷 + +authority spine 確實同構(B 的 mandate/derives ≡ E 的 attenuation:都是 issue-time 檢查、都以 owner 為 root、都是 scope/budget/expiry 收斂),但義務 vs 許可是反轉的。情境:owner 簽給 claude 三次發布額度,claude 一次都沒用。E:grant 耗盡/過期,帳面乾淨,沒有 broken 這個概念,什麼都沒發生。B:mandate 本身就是一筆承諾,一筆未兌現 → obligor 違約 → 有人被歸責。第二:E 的捕捉在圖外、零可見度、會過期(「沒有人願意為它簽字,就是它的判決」);B 的捕捉是永久的債(horizon=null 永遠不逾期,這正是 B 自承的 PARA 後門)。 + + +### promise-network ↔ declarative-solver — 不塌陷 + +完成的判準相反:B 是有意向的 witness 押信用 attest,F 只認機械 probe(「沒有人能打字打出 done」)。B 的 primitive 強制 obligor + horizon(且都是 obligor 自己給的,到期日是唯一的緊迫性);F 兩者皆無(無 assignee、無排程、無優先級)。兩者的紅堆確實都會被壁紙化,但堆的成因與可清償性不同:B 的堆可以靠 attest/withdraw 手動清(只是 owner 懶),F 的堆在機制上清不掉(沒有 probe 就永遠啞著,且「你不能手動標 done,這正是模型引以為傲的機制」)。 + + +### prose-native ↔ effect-reconciliation — 不塌陷 + +C 的話是自由散文、無形式要求、靠時鐘淡出;D 的條目必須是可觀察的世界狀態句,而那個形式要求正是 D 的死因。情境:凌晨兩點打「腳踏車該修了」——C 收下它,它會在 音量 上自然沉下去,永遠不需要被處理;D 也收下它,但它永遠是一句無法被觀測的模糊 未然,永遠不沉、永遠等 refine(D 的第一死法逐字如此)。第二:C 完全沒有邊,D 有四種時間邊 + 可計算的 lineage。第三:C 拒絕機器的一句話(agent 在 schema 裡無處說話),D 允許 agent 簽 已然。 + + +### prose-native ↔ grant-graph — 不塌陷 + +C 的 條 與 E 的 grant 表面很像(自由文字 + effect class/surface + 次數/額度 + 到期),但兩處反轉。(1) 無記名 vs 有 holder:C 的許可是 bearer 的,系統在機制上答不出「哪個 agent 動的手」;E 的 holder + attenuation path 是結構事實,O4/O6 全靠它。(2) 主從反轉:C 裡散文是永久的一等公民、條 是「極少數的話身上掛的」附掛;E 裡沒簽字的東西不是公民(圖外、零邊、零 projection、shelf life 過期後丟進刻意難用的原始流)。E 的核心命題「工作不是實體,是 spend 的影子」在 C 裡直接為假:C 的話不需要任何許可就存在且永存。 + + +### prose-native ↔ declarative-solver — 不塌陷 + +直接對立,幾乎不需要情境。C 的 primitive 就是散文;F 明確拒絕「散文、日記、心情、私人衍生物——沒有任何欄位能裝一段文字」。C 的讀取面是 音量(時鐘排序);F 拒絕任何儲存的排序(「排序是 owner 在查詢當下問的問題,永不儲存」)。C 沒有真值的概念(意圖永遠不會完成,只是變得聽不見);F 的一切都是 probe 的真值,落差是唯一產品。 + + +### effect-reconciliation ↔ grant-graph — 不塌陷 + +命題相反。D:條目先存在、永遠是一等公民、envelope 是後來才簽在它上面的(「一行字就是合法的一等公民,跟一個被完整授權的效果是同一個型別」);E:沒有簽字就沒有節點,沒有節點就沒有動作/計數/projection,空殼還會過期。D 的結構是時間鏈(refine/supersede/respond/evidence),E 只有 attenuation 一種邊。D 的核心動作是對帳(intent vs 獨立觀測);E 明確把 completion 從 producer 的詞彙表裡拿掉(只能說「我花完了」),且 reconcile 永遠標記為 owner 的宣告而非系統得知。 + + +### effect-reconciliation ↔ declarative-solver — **塌陷** + +塌陷。靜止時同構:一列「對世界狀態的宣稱」+ 一個二值標籤區分宣稱與觀測(D 的 未然/已然 ≡ F 的 assertion/observation)+ 封閉邊集合 + 零 status + 全部 read-time 算。真值機制同一套:只有獨立跑的探針/觀測能給真值、沒有人能手動標 done、真值會因重新觀測而自動失效(D「明天世界可能再變,這句話會自動失效」≡ F「真值 = 最近一次觀測」)。拒絕清單幾乎逐條重疊:步驟/順序/依賴、手動 status、assignee、優先級、容器、進度、retry、cancelled effect、risk。產品同一個(相左/落差),死法同一個且連機制描述都撞——D 的「探針要寫、憑證要分、外部讀取路徑要維護」就是 F 的「判準稅」,兩者最後都是紅堆失去可信度 → 被繞回 Obsidian。殘留只有兩點,且都不是另一個想法:(1) F 多一種 effect record 列——但 F 自己說「硬把效果塞成斷言的欄位,S5 就會說謊」,也就是 F = D 自我稽核後把 S5 的洞補起來,那是同一個模型的補丁(唯一真行為差:撤回後才落地、且無人觀測的效果,F 的寫入紀錄看得到,D 看不到);(2) F 的 solver 吐衍生斷言長成樹、D 只長 chain——但 solver 是 F 全篇唯一未被機制化的器官(誰在解?怎麼解?),而且諷刺的是「要 A 為真需要 B 為真」正是 F 自己拒絕清單上的『依賴』。核心命題兩邊逐字相同:owner 只能斷言世界狀態,只有獨立探針能讓它為真,產品是落差,稅是探針。 + + +### grant-graph ↔ declarative-solver — 不塌陷 + +E 沒有真值的概念(拒絕 completion,producer 只能說「花完了」,reconcile 永遠標記為 owner 的宣告);F 的一切都是真值(probe 的最後回答,且沒有人能宣告)。E 的捕捉在圖外、會過期、零可見度、不進任何計數;F 的捕捉是一句啞的斷言,永遠可見、永遠在堆裡、清不掉。E 的結構是 attenuation 樹(authority),F 的是求解樹(proof search)。死法不同:E 退化成一個仍在正常運作的授權層(唯一的非「紅堆」死法,它自己也誠實承認可能只是一層),F 是單點價值系統,那個點髒了就整個死。 + +--- + +# 附錄 B —— 方法論與其偏誤(由編排者自述,非 workflow 產出) + +## 怎麼跑的 + +32 個 agent,五階段: + +1. **Generate** — 6 個 generator,每個被指派一個 (primitive × structure-origin) 格子。**Blind**:明令禁讀 repo/code/schema/既有 contract,禁用 PARA/GTD 詞彙當骨架。目的是擋掉「現況的換皮」。 +2. **Stress** — 每個模型交給一個**獨立**測試者,明令不信設計者自己的情境回答,從機制重新推導 S1–S12,verdict 含 `UNANSWERED`(= 設計者給了宣言而非機制)。 +3. **Kill** — 每個模型被三軸攻擊:O10(generic engine)、塌陷(會不會長回 PARA;**唯一 incumbent-aware 的一軸**)、outcome-violation。 +4. **Collapse** — 15 對逐對判定 material difference。 +5. **Synthesize** — 比較矩陣 + 存活集。 + +## 我注入的偏誤(必須攤開) + +**O10 軸的殺手被我下了 `預設 lethal = true`。** 原文:「預設 lethal = true,除非模型能證明它的拒絕是結構性的。」 + +這意味著 **O10 軸的 6/6 lethal 有一部分是我的 prompt 製造出來的,不是純粹的發現**。這正是 `feedback_verify_the_probe` 說的:一個回報 100% 違規的探針,更可能是壞掉的儀器。 + +三點反向證據,說明結論仍然站得住: + +1. **prose-native 在 O10 軸拿到 `lethal = false`** — 有模型翻過了這個 prior,所以它不是不可逾越的橡皮圖章。 +2. **塌陷軸沒有任何 prior,而它是 6/6 lethal。** 這一軸是「0 存活」結論的真正承重點,不是 O10 軸。 +3. **outcome-violation 軸同樣沒有 prior。** + +**所以誠實的說法是**:「0 個存活」主要由**塌陷軸**支撐(無 prior、6/6);O10 軸的 6/6 應打折看待。 + +## 其他限制 + +- 全部是**語意推導,零 runtime/test 證據**。每個 counterexample 都是 Inference,不是 Fact。 +- **關於現況的每一句都是 assumed** — synthesizer 讀的是 CLAUDE.md 與記憶索引的文字,不是跑過的 code。§5(6) 的「你已經在跑」與 §6 附錄的 `resolve_todo` pointer 都需要用 code 驗。 +- generator 是 blind 的,所以它們對現況的無知是設計使然;但 synthesizer 是 incumbent-aware 的,它對現況的宣稱繼承了文件的準確度,不是 code 的。 +- **§5(6) 的 O7 衝突(SSR runtime vs frozen bytes)是這份報告唯一可立即用 code 證偽的一格。** diff --git a/docs/owner-locks.md b/docs/owner-locks.md new file mode 100644 index 000000000..dba871322 --- /dev/null +++ b/docs/owner-locks.md @@ -0,0 +1,250 @@ +# Owner Locks — the constraints no design may violate + +> **Status: anchor document.** Until 2026-07-25 these constraints existed only +> in an agent's memory index and in chat, never in this repository. That is why +> successive designs kept re-deriving them wrong. This file is now the record. +> +> **Authority:** these are the owner's rulings, not derivations from code. Code, +> schema, and every other document in `docs/` are subordinate to this file. +> Where the code contradicts a lock, the code is the thing that is wrong. +> +> **Not authority:** nothing here defines a taxonomy. See §3. + +## 1. Product-semantics locks (2026-07-15) + +1. Koopa is a single-owner, PARA-informed **execution + publication system**. +2. Obsidian / Yomihon are the **knowledge side**. They may pair with Koopa but + must not depend on it, and Koopa must not depend on them. If either is + offline, the other's core still holds. +3. **Diary lives only in Obsidian.** It never enters agent context, the Koopa + database, logs, reports, embeddings, or any public surface. +4. **Vault Markdown is the authoring truth.** Koopa receives a publication + snapshot — never a second authoring copy. Revision means: edit the Vault + first, then submit a new Git blob SHA. A publish receipt is written back to + the Vault. +5. Koopa does **not** need embeddings, knowledge search, public or admin search, + related-content, or a graph. Any existing search stack is a removal target. +6. **Publish is always admin-only.** +7. Frontend design is deferred; product semantics and the backend contract come + first. +8. Agent autonomy is a **middle ground**: no per-item approval, and also no + unbounded backlog scanning, no arbitrary execution, and no self-acceptance. +9. GitHub and Linear are **references, not design boundaries**. +10. Do not assume a small fix. A large refactor may be proposed, but must be + proven. + +## 2. Locked outcomes (2026-07-16) + +The 2026-07-16 pivot superseded the chat-only "Semantic Contract v0.2" and +locked **outcomes** rather than a model. A design is judged against these; it is +not judged against any particular set of entities. + +| # | Outcome | +|---|---| +| O1 | Fast capture with zero classification — **and** the captured thing can still be advanced later. | +| O2 | Planning and tracking. | +| O3 | Bounded autonomy without per-item approval. | +| O4 | Agents must not expand commitment. | +| O5 | Cancellation, retry, timeout, and effect are reported honestly. | +| O6 | Verification and acceptance cannot be forged by the same lineage that produced the work. | +| O7 | Vault source, authorization, and public effect are three separable things. | +| O8 | Diary and private derivatives never enter Koopa. | +| O9 | External knowledge being offline still leaves planning usable. | +| O10 | Must not degrade into a generic workflow engine. | + +## 3. What is explicitly NOT locked + +No taxonomy is locked. PARA, GTD, Area, Project, Action, Routine, Goal, Todo, +Resource, Archive, Commitment, Inbox, and Candidate are **all disposable +hypotheses**. Their presence in the current schema is an artifact of history, +not a commitment. + +`docs/para-semantic-contract.md` predates this and is therefore **not** a lock. + +## 4. Superseded + +- **Semantic Contract v0.2** — chat-only, never had repository authority, + superseded 2026-07-16 over PARA/taxonomy anchoring problems. It must not be + reverse-reconstructed from code, schema, or any audit report. +- Everything that depended on v0.2: the two 2026-07-16 Stage A cold audits and + the Stage B cross-review. + +## 5. Why the system was abandoned — owner testimony, 2026-07-25 + +The single hardest piece of evidence about this product. Not a derivation, not +an audit finding: the owner's own account of using it and stopping. Recorded +verbatim in substance because every prior design round guessed at this and +guessed wrong. + +**He did not fail to adopt it. He adopted it, used it, and quit.** + +On capture and classification: + +> I've wondered whether I broke the whole architecture by force-fitting PARA. I +> should have focused on the **flow**, not on PARA's definitions. It genuinely +> doesn't flow. I have to keep thinking: does this belong under project? goals? +> areas? todo? It got confusing. Daily tasks, goals, and so on — I didn't know +> how to plan them or complete them, and eventually I basically gave up. There +> are many things I want to do and quite a few goals, and I do want to track +> progress properly, but I originally studied GTD, PARA, and Notion +> "second brain" concepts and fitted them in, and that is what wrecked it. + +His actual domains, named by him: **Japanese, software development, Muay Thai, +literary reading, ArdanLabs training.** These are long-horizon *practices*, not +projects with an end date. Any model that requires them to be classified as +project-or-goal-or-area is reproducing the failure. + +On execution — he reported all four offered friction points as real: + +1. The daily brief / Today list does not match what he actually intends to do, + so he does not trust it. +2. Agent proposals must be approved one at a time, which is itself work. +3. Publishing an article takes too many steps. +4. The admin UI and the agent-facing MCP surface feel like **two different + systems** that he must translate between in his head. + +And what he actually wants instead: + +> Some of what I want is like what Karpathy and the Claude / Anthropic team +> advocate — the **loop engineer**. Some things I can let an agent loop on +> autonomously: find tasks, plan tasks, work toward the goal. The agents include +> Claude, Claude Code, and the Hermes agent. Autonomously loop to complete +> tasks, plan tasks, push toward the goal — or write reports into Obsidian. +> Things that genuinely need my decision come back to me. But I keep failing at +> this point, I keep getting stuck, I don't know how to design it. My +> understanding of loop engineer is: **if the flow is designed well, you don't +> even need a great model — a good process lets them loop.** + +**The reframe this forces:** O3 (bounded autonomy without per-item approval) was +written as a *constraint on* a planning tool. The testimony says it is the +*center of the product*. The system is a runway for agent loops; planning +containers are at most a means to that end, and the PARA-shaped ones have been +actively harmful. + +**Also settled 2026-07-25:** production data is entirely disposable — "全部可以 +砍掉重來". With four migrations and no data to preserve, schema redesign carries +no migration burden. + +## 6. Decisions of 2026-07-25 + +Made in response to the refactor inventory. These are rulings, not proposals. + +1. **Obsidian (read through yomihon) is the single source for all knowledge and + article organisation.** Owner's reasoning: he does not want one copy here and + another in the vault, hard to sync and scattered. Consequences: + - Koopa holds **publication snapshots only**. It is not an authoring surface. + - The in-app authoring path is **removed** — the admin content editor keeps + review and lifecycle transitions and loses its body editor. + - This closes the open question in `refactor-inventory-2026-07-25.md` §4.2 + about whether the authoring path survives. It does not. + - It also strengthens lock 4 rather than replacing it. +2. **Unobserved is the same as nonexistent.** Owner's words: "if it isn't used, + it's all bad, it's the same as not existing. No observation, no use — then red + or green is irrelevant." Consequence: do not build honesty or reporting + machinery for a surface that has no consumer. Establish the consumer first. +3. **Production data is disposable in full** — "全部可以砍掉重來". Schema + redesign carries no migration burden. +4. **Whetstone is a future experimental project**, not a current dependency. Its + intended posture is *sharing* rather than teaching, over Go / Rust / Docker / + Kubernetes. It is not designed yet and comes after this work. Knowledge still + lives in Obsidian regardless. Note for whoever picks this up: the + `sync-vault-publish` cron has already run 37 times publishing into a + "Whetstone content/" path — the cron predates the design. +5. **Goals and milestones: delete.** Owner could not say what they did for him; + that is evidence about the design, not a gap in his understanding. Every + primary source justifies a goal/milestone layer with a *coordination* job — + Linear's milestone answers "where are we?", its Initiative is a renamed + Roadmap for "monitoring at scale", and SRE's error budget exists to arbitrate + a two-party incentive conflict. There is no second party here. The two + systems actually designed for one person (org-mode, OmniFocus) have no goal + entity at all. **The 2026-06-24 `propose_goal` owner-LOCK is reopened by the + owner and goals may be deleted.** +6. **Japanese is not measured.** Owner: it is a genuine intrinsic pull, not + credentialing — "I simply want to learn it well, so I can read Japanese + literature in the original." Measuring an activity framed as intrinsic + leisure reduces the likelihood of continuing it. No progress representation + for Japanese, in any form. +7. **Muay Thai is out of the system entirely.** Arrangements are verbal with the + coach; there is no machine-readable trace and no candidate channel that would + not fire on days he did not attend. Not "not yet" — out. +8. **Koopa does not read the Vault's git log.** Preserves lock 2. Independently + justified after the fact: yomihon's `(via yomihon)` commit suffix cannot + carry a human signal — `internal/status/status.go:109` is + `const actor = "koopa"`, a compile-time constant, and `POST /status` has no + authentication, so any local process can produce a byte-identical commit. +9. **Owner-facing notification is a push channel** (Telegram / Discord / Slack), + not a surface he must visit. A queue he has to open is the failure mode that + already happened. + +### 10. The public site is built, not served (2026-07-25, proven by spike) + +Two independent designers, working from the same brief without seeing each +other's work, converged on the same end state: no Postgres, no MCP tools, a +static public site, and publication as a pull request the owner merges. The one +technical claim that decision rested on had been refuted — the build output +cited as SEO evidence turned out to be gitignored, so it could not show that +HEAD reproduced it. + +So it was built. On a clean worktree at HEAD, with article routes switched to +`RenderMode.Prerender` and `outputMode: "static"`, `ng build` prerendered 13 +routes including every article, and emitted no server bundle at all. Checked +against the live SSR response for the same article: + +| | title | description | canonical | og | twitter | JSON-LD | +|---|---|---|---|---|---|---| +| static | 1 | 1 | 1 | 6 | 6 | 1 | +| live SSR | 1 | 1 | 1 | 6 | 6 | 1 | + +The canonical link is byte-identical. `BlogPosting`, `Organization`, `Person` +and `WebPage` JSON-LD are all present in the raw bytes, the article body is in +the HTML, and a 15 KB transfer-state payload is embedded, so first paint needs +no API call. Nothing about SEO, RSS, or deep links requires a running server — +deep links get more robust, because each slug becomes a real file on disk. + +**Ruling: the public surface becomes a build artifact.** `sitemap.xml`, +`feed.xml` and `security.txt`, currently generated by the Express server, become +files written at build time. + +### 11. Publication is a merge (2026-07-25) + +Lock 6 says publish is admin-only. Owner merging a pull request satisfies it — +only he can merge — and strengthens it: the gate becomes a diff he has read +rather than a button he clicked. `status: ready` in the Vault is an **editorial** +state, not publication intent (he said he will choose what to publish later), so +nothing is ever published automatically and the published set starts empty. + +### Two corrections that constrain any future design + +- **Commit provenance is destroyed and unrecoverable.** All 114 Vault commits + and all 100 sampled repo commits carry the owner's identity, *including* agent + output with subjects like `hermes: rust batch 06-24`; five hermes crons also + touch git. The repo's own rule — "NEVER include Co-Authored-By. No attribution + lines of any kind." — removed the only distinguishing field. "A commit exists" + does not mean "a human worked". Repairable going forward with a distinct + `GIT_COMMITTER_EMAIL` for agents (the rule bans message trailers, not + committer identity); the past is not recoverable. +- **The "measure which textbook unit" idea fails on the owner's own artifacts.** + `~/obsidian/Writing/lessons/japanese/` holds 27 files whose 16 frontmatter + keys contain nothing about the learner; `status` there is editorial review + state of companion-reading material. The textbook is paper. The trace does not + exist — it is not merely missing. + +### The verifier gate (established 2026-07-25) + +A domain may be looped **only if it has a fast, objective verifier the agent +cannot edit**, and the evidence must appear **without the owner doing anything** +— he has already abandoned one system that required manual logging. A +false-positive evidence channel is worse than no channel: vault mtime would +report Japanese activity on a day an agent rewrote files. + +## 7. Open, and only the owner can close them + +Recorded here so no future session silently closes one. + +| Open question | Source | +|---|---| +| Is the work distribution self-heavy or agent-heavy? Measurable: the share of `activity_events` with `actor != 'human'`, and the count of contents that reached `published`. | Six-model exploration, Q1 | +| Is overnight, multi-step, mid-flight-accepted agent work actually wanted? O3 × O6 × O10 are three-way incompatible on that scenario; if it is not wanted, the incompatibility never fires. | Six-model exploration, Q2 | +| Three months after capture, must the system be able to find that item again? O1's second half, the no-search lock (§1.5), and zero-taxonomy are mutually exclusive — one of the three has to give. | Six-model exploration, Q3 | +| Does the serving path emit frozen bytes, or render at request time? A request-time renderer breaks `(source_hash, renderer_hash)` publication addressing. Must be settled against code, not documents. | Six-model exploration, §5(6) | +| Is `resolve_todo` closing one's own todo an attestation about a deliverable (O6 applies) or private cleanup of one's own scratch note (O6 does not)? | Six-model exploration, appendix | diff --git a/docs/refactor-inventory-2026-07-25.md b/docs/refactor-inventory-2026-07-25.md new file mode 100644 index 000000000..e176445fe --- /dev/null +++ b/docs/refactor-inventory-2026-07-25.md @@ -0,0 +1,490 @@ +# Refactor inventory — 2026-07-25 + +> **DISPOSABLE. Delete this file once the staged plan in §5 has been executed or +> rejected.** It is a dated measurement, not a description of the system — it +> will be wrong the moment the code changes, and it must never be maintained to +> match. Anything in it that turns out to be a durable ruling belongs in +> `owner-locks.md`; everything else dies with the file. +> +> Read `owner-locks.md` first; it holds the constraints. This file holds the +> measurements and a staged plan. Nothing here is a decision — every deletion +> below is a proposal awaiting the owner. +> +> Method: 12-agent read-only census + 5 external research briefs + a +> completeness critic, plus first-hand verification of the load-bearing claims. +> The critic spot-checked 12 census claims and **5 failed**; those are corrected +> in place below. Claims are marked VERIFIED (read from code or runtime output) +> or INFERRED. + +## 1. The diagnosis + +**The agent loop is not broken. It runs, reports success, and cannot converge — +because its only exit is the owner's hand in the admin UI, and it has no way to +observe whether the work happened.** + +This is not inferred from design documents. It is the system's own output. + +### Evidence A — 24 days of an identical plan (VERIFIED, runtime) + +`~/.hermes/cron/output/1a3b0f90d594/` holds 26 run records for the `plan-day` +cron. From 2026-07-02 through 2026-07-25, **19 successful runs emitted a +byte-identical line**: + +``` +今日主線(草案,去 admin 精修): + 1. Kotonoha 句層擴充:phrases 35→~80(は/を/へ 文法句優先)(在途) +``` + +Same item, still `在途`, every day, each one instructing the owner to go to +admin. Three further runs logged `plan_day write failed` (network timeouts) and +still recorded `last_status = ok`. + +This is simultaneously the mechanical cause of two owner complaints: "the daily +brief is not what I actually intend to do" (it has been the same stale row for +24 days) and "I opened it, the flow felt wrong, I stopped". + +### Evidence B — a proposal loop that proposes nothing (VERIFIED, runtime) + +`proposal-loop` has completed **158 runs** (5×/day since 2026-06-17), every one +`last_status = ok`. Of the 50 retained run records: + +| Outcome | Runs | +|---|---| +| `new=0 reconfirmed=0 pruned=0 removed=0` with `llm_no_output=1` | **46** | +| produced any ledger movement | 4 | +| produced a genuinely new proposal | 2 | + +92% of retained runs did nothing at all and reported success. 36 proposals sit +incubating in `~/.hermes/proposals/inbox/`, gated on a "seen ≥ 2" rule that a +loop producing no output can never satisfy. + +Root cause of the empty output is plausibly infrastructure (the records show +`離站:A5000 不可達 → Nous + Sonnet 5` fallback), not design — but the +**reporting** is the defect: a run that produced nothing is indistinguishable +from a run that worked. That is outcome O5 failing in production, on the loop +the owner most depends on. + +Note also: the loop's own prompt instructs it to prioritise "推進上面 PARA +goal" — so the loop is anchored to the very taxonomy that broke. + +### Evidence C — the act-moment write path is dead (VERIFIED) + +`.claude/settings.json:166` fires on **every** `git commit` and instructs the +agent to call `mcp__koopa0_knowledge__log_dev_session`. That tool has **zero +occurrences** in `internal/mcp/ops/catalog.go`. The same dead name appears in +`.claude/commands/build-log.md:3` and `.claude/skills/build-log/SKILL.md:11`. + +Git history shows 4–63 commits/day. So the single highest-frequency moment in +the owner's actual working day has been firing an instruction to call a +nonexistent tool, silently, for months. + +### What this means for the stated pains + +| Owner's stated pain | Verdict from evidence | +|---|---| +| "Go best practices 沒做好" | **Not supported.** `go build`, `go vet`, `golangci-lint`, `staticcheck -checks=U1000` all clean. 165/165 sqlc query names have callers. Exactly **one** truly unreachable function in ~28.6k non-test LOC. No forbidden deps. | +| "功能刪刪剪剪 遺留技術債" | **Real but small in code.** The verified-dead set is a few hundred LOC. The churn was real (see §3) but the removals actually completed. | +| "語意 心智模型混亂" | **Strongly supported.** Three parallel vocabularies, 32 terms for 7 concepts (§2). | +| "錯誤的決策一直做下去" | **Supported, and concentrated** in ~6 named places, not spread through the architecture. | +| "流程不順暢 / 硬套 PARA" | **Strongly supported, and it is the real problem** (§1 evidence A/B). | + +**The refactor is justified — but the budget should go to the loop's +convergence test and the vocabulary collapse, not to a rewrite.** The code +quality is fine. Rewriting it would spend the entire budget without touching +the cause. + +## 2. Mental-model confusion, quantified + +Three vocabularies name the same things (VERIFIED): + +| Layer | Vocabulary | +|---|---| +| Schema / PARA docs | area · goal · project · milestone · todo · content · topic | +| MCP (14 tools) | brief · plan_day · capture_inbox · propose_* · list_* · resolve_todo · review_period · project_progress | +| Admin HTTP (82 routes) | **commitment** · **knowledge** · **system** | + +`commitment` appears nowhere in the MCP surface and is not an entity in the +schema. Every switch between admin and agent requires a mental translation. + +**32 distinct terms across 7 concept families** (VERIFIED by count): +work-item 5 (todo 75 / commitment 43 / item 33 / task 16 / action 9), +publication 6 (content 89 / article 24 / publication 21 / snapshot 17 / post 13 +/ piece 3), actor 5 (agent 223 / caller 61 / actor 51 / creator 16 / author 1), +capture 5, domain 4, objective 4, container 3. + +Surface-area ratio: **82 HTTP routes vs 14 agent tools** — the human interface +is 5.9× the agent interface, in a system whose owner says the centre should be +agent loops. + +## 3. Scale and history + +| Measure | Value | +|---|---| +| Go non-test LOC | ~28.6k (8.2k of it sqlc-generated) | +| Go test LOC | ~23.4k | +| Frontend LOC | 37,151 (23,411 non-spec + 13,740 spec) — **larger than the backend** | +| Tables | 17, across 4 migrations | +| HTTP routes | 82 | +| MCP tools | 14 | +| docs/ | 23 files / 3,469 lines / 268 KB | +| `.claude/` (gitignored) | design 1.17 MB · skills 634 KB · agents 101 KB · rules 74 KB | +| node_modules | 548 MB, 833 lockfile entries, 166 JS chunks | + +**Churn (VERIFIED):** 843 commits on main; **198 (23%) are remove/retire/drop/ +delete**. `internal/research` lived 1 day. `internal/song` lived 4 days — and +was added to the search corpus one day before deletion. `internal/reading` +lived 12 days. The MCP catalog size oscillated +10→11→13→11→12→13→16→14→15→14→15 across seven days in June. + +**Four eras:** (I) pre-rewrite, orphaned — 8 branches root at `3ee63da9` with +**no merge-base to main**, holding up to 483 commits. (II) build-out. (III) the +churn era — June, 548 commits = 65% of main. (IV) the alignment era, 07-17→ +07-20, where deletions finally trace to owner locks. + +**Correction to a prior memory:** PRs #44/#45/#47 were recorded as "the most +aligned code". Only **#44** is genuine lock-4 work (migration 003). #45 is lock +6, and #47 is 12 files of frontend label copy. The genuinely most-aligned work +is **PRs #34–#39** (07-18→07-19), which deleted the search/embedding/graph +stack per lock 5. + +**Lock 4 is PARTIAL:** no Vault receipt writeback exists, and `renderer` / +`render_hash` / `content_hash` return **zero hits repo-wide** — surviving +invariant (b), the (source bytes, renderer) pair, is unimplemented. + +## 4. The three deferred decisions — answers + +### 4.1 Taxonomy: no lock has to give + +The three-way conflict (advance-a-3-month-old-capture × no-search × +zero-taxonomy) dissolves once the axes are separated: + +- **"What am I working on this month" is a TIME question.** +- **"Find the capture from 3 months ago" is a STALENESS question.** +- Area / Goal / Project / Milestone are all on the **SUBJECT** axis and answer + neither. *That* is why six models regrew containers and still failed — they + expanded the axis that was never load-bearing. + +Smallest set that works, argued from Things 3, org-agenda, OmniFocus, Linear, +Basecamp, Shortcut: + +| Keep | Why | +|---|---| +| **Action** (`todos`) | the only mandatory row — the unit of "advance" | +| **Project** (nullable FK on Action) | the enumeration unit for "this month" (3–7 rows read in ten seconds); and a capture becomes advanceable exactly when it acquires a Project + a next action. Has rename, merge, cascade — the maintainability zero-taxonomy destroyed. | +| **a target date field** on Action (`todos.due`, exists) | "this month" is a `WHERE due BETWEEN …` view, not a container. Things and org-agenda ship **zero** period containers. | +| **a staleness rule** reaching the capture | prefer org-mode's structural rule ("no open next action") over a timed interval — no clock, no per-item config. `internal/project/progress.go:167 Stalled()` already does this; it just never sees an inbox row. | + +- **Area** — arguable. Its one real payload is `activity_events.area_id` + backing the 14-day neglect signal. +- **Goal + Milestone** — weakest. Two containers on one axis. Shortcut loosened + Epic→Objective from 1:1 to many-to-many in 2024 — the signature of a layer + drifting from container toward tag. Owner's call. +- **`goals.quarter TEXT`** is already the degenerate period container: a period + label with no rename, no merge, no cascade — the exact pathology the owner + diagnosed in zero-taxonomy, sitting in his schema today. + +**Search ban survives — enumeration is not search.** `WHERE state='inbox' ORDER +BY created_at` over a complete list needs no index, no embedding, no ranking. It +breaks only when the pile exceeds skimmable size. The real cost is not an index, +it is an **obligation** to look at the queue: OmniFocus schedules it, org-mode +computes it, Basecamp abolishes the queue. There is no fourth option. If both +the obligation and search are refused, retrieval is genuinely unachievable and +the no-search lock is the one that gives. + +### 4.2 Frontend: not now, and for a better reason than migration risk + +- The SSR/SEO pipeline was **runtime-verified working** against the committed + build: `/articles/:slug` emits title, description, 5 OG tags, twitter card, + canonical, and full `BlogPosting` JSON-LD in the raw byte stream before + hydration. RSS and sitemap are already server-side in Express. +- **The O7 "SSR breaks content-addressing" worry is wrong.** Nix binds store + paths to *inputs*, never hashing output. Astro states explicitly that the + frozen build-time store applies "whether the page is prerendered or server + rendered on-demand". Argo CD keys its manifest cache on (source revision, + source config, renderer environment) and re-renders every reconcile. + **koopa is already Astro-shaped**: `contents.body` + `source_git_blob_sha` is + the frozen store; Angular SSR is the on-demand renderer. +- What is actually missing is **renderer identity**: `marked@^17.0.4` and + `highlight.js@^11.11.1` are caret ranges — the renderer is neither pinned nor + recorded. And `markdown.service.ts:107-111` skips DOMPurify on the SSR path, + so SSR HTML and hydrated HTML already differ byte-for-byte. Output-addressing + was never available; input-addressing is, and is cheap. + +**The real hinge** (frontend census): whether the content editor's *authoring* +path is deleted. If the admin becomes review + lifecycle transitions only, it +collapses to lists and forms and templ becomes easy. That is a product decision +upstream of the framework question. + +Only interaction found that native-web-first cannot match at equal fidelity: +drag-reorder on `/admin/daily/plan`. + +### 4.3 The loop: what it is missing + +Every control loop that survived a decade in production (Kubernetes +reconciliation, Temporal, MAPE-K, Dagster assets) shares the one thing this +system lacks: **an externally computable convergence test** — declared intent +vs. observed evidence, where "observed" does not mean "the owner clicked". + +The three concrete gaps, in priority order: + +1. **No convergence test.** `plan-day` re-picks the same row because nothing + else can change that row's state. Any evidence source that is not the admin + UI (a git commit, a file in the Vault, a cron artifact) breaks the deadlock. +2. **No honest effect reporting.** `last_status = ok` on a run that produced + nothing. `brief` returns byte-identical output for "nothing overdue" and + "the query failed" (`brief.go:294-335`) — the clearest O5 violation, on the + most-called tool. +3. **No exit that is not the owner's finger.** Every loop terminates in + "去 admin 精修". + +## 5. Staged deletion plan — proposals, not done work + +`docs/` is now fully tracked (commit `e8149b51`), so **everything below is +git-recoverable — except `.claude/`, which is gitignored and is not.** + +### Stage 0 — free, no product decisions + +| Delete | Evidence | +|---|---| +| `project.Store.TitlesByIDs` + `ProjectTitlesByIDs` query | the one truly unreachable func; query comment cites "annotating search results" (lock-5 remnant) | +| `todo.ErrInvalidTransition` | declared, never returned, never checked — its doc comment is a false specification | +| `mcp.Server.feeds` / `mcp.Server.stats` fields | write-only; alone they drag `internal/feed` + `internal/stats` into the MCP binary | +| `defaultSectionsByAgent` + `resolveDefaultSections` + the test pinning its emptiness | the map is empty; the resolver always returns nil | +| `process_runs.attempt` / `max_attempts` + their CHECKs | zero writers | +| `internal/content/admin.go:173-200` | ~28 lines of validation followed by an unconditional 410 | +| `skills/koopa-system.skill` (zip) | ships a retired `search_knowledge` in 8 places; the unpacked dir is the corrected copy — **confirm no external Cowork install consumes it first** | + +**Fix, not delete:** the dead `log_dev_session` in three files (restore the tool +or remove the references — a hook that silently fails on every commit trains +agents to ignore hook output); `brief`'s per-section status/errors block. + +### Stage 1 — the clearest mandate, and NOT the lowest risk + +Drop the search stack: `CREATE EXTENSION vector`, `contents.embedding`, +`contents.search_vector`, the GIN + HNSW indexes, the `sqlc.yaml` overrides, the +`pgvector-go` direct dependency, the testdb pgvector image pin. + +This is the only deletion with a **measurable recurring cost** — a tsvector +recomputed and a GIN index maintained on every content write, for a query that +does not exist. + +**Three hard constraints:** +1. `guard_content_withdrawal_metadata` names `search_vector` at + `migrations/004:100,102` — it must be `CREATE OR REPLACE`d **in the same + migration**. That guard is the DB-level freeze on published bytes. +2. **New numbered migration only.** Never edit 001 — this project has already + taken a production startup crash from an in-place migration edit. +3. CI **auto-deploys to the VPS on merge to main**. + +### Stage 2 — docs, after extracting decisions + +- `docs/work-orders-2026-07.md` (57 KB): **only 2 of 20 PRs ever completed** + (PR-0, PR-1). The whole G-1→W-12 apparatus was gated on W-5, which never + happened, so it never fired. PR-18/PR-19 are self-marked `SUPERSEDED — DO NOT + EXECUTE`. It "explicitly protects" `internal/search`, `internal/embedder`, + `/admin/knowledge/search` and `SimilarContents` — **none of which exist**. + The file itself records the pending decision at line 11. → extract the ~4 live + decisions into `owner-locks.md`, delete the rest. +- `docs/reviews/*`, `docs/audit-prompts/*` — superseded process artifacts. Note + `audit-prompts/` is wired into `.claude/skills/adversarial-review/SKILL.md:32-37`; + they retire together or not at all. +- `docs/para-semantic-contract.md` — demoted by `owner-locks.md`; retire or + rewrite once §4.1 is ruled on. +- `docs/backend-semantic-contract.md` — 10 of 12 sampled claims accurate. Two + drifts: §3 lists `assignee` and `curated_by` as FK actor columns; **neither + exists in any migration**. + +### Do NOT delete — the critic's list + +| Item | Why it is not dead | +|---|---| +| `internal/mcp/flex.go` (`FlexInt`) | **an unapplied fix, not dead code.** Its own MUST rule is violated *today* by four live tool-input fields (`capture.go:35,36`, `recurrence.go:43`, `execution.go:35`). It is the only in-repo record of upstream bug `anthropics/claude-code#26027`. The correct action is the inverse: convert those four fields. | +| `project.Store.Projects`, `Store.UpdateStatus` | have integration-test callers; deleting breaks the `-tags integration` CI job that gates auto-deploy. (Both censuses were also **wrong** that project status is untransitionable — `PUT /api/admin/commitment/projects/{id}` reaches it via `UpdateProject`.) | +| `ops.Meta.Since` | the only machine-adjacent record of when each tool entered the surface, during a refactor that will churn that surface. (`Meta.Stability` is genuinely single-valued and safe.) | +| admin `PUT /api/admin/knowledge/content/{id}` | wired to a live editor with a save button; deletion turns a silent failure into a 404. Its `source_vault_path IS NULL` clause **is** the lock-4 enforcement — relaxing it to "fix" the editor is the dangerous direction. Product decision. | +| Squashing migrations to a new baseline | 001 was edited in place across 40+ commits and the running DB never received the later edits. A squash destroys the only means of reconstructing what was actually applied. | +| The 8 orphan branches at `3ee63da9` | the only surviving copy of ~6 weeks of pre-rewrite work, with no merge-base to main. Tag or bundle before any branch cleanup. | + +## 6. Live bugs found (not deletions) + +1. **The admin content editor cannot save anything the system can create.** + `content-editor.page.ts:343` → `PUT /api/admin/knowledge/content/{id}` → + `query.sql:107-126` `UPDATE … WHERE id = $1 AND published_at IS NULL AND + source_vault_path IS NULL`. But `propose_content` (`proposal.go:344`), the + only live content creator, **always** sets `source_vault_path`. So the UPDATE + matches 0 rows → `ErrNotFound` → "Failed to save draft." + (source-chain VERIFIED; runtime INFERRED) +2. **CI never runs `npm test`.** 13,740 LOC of Angular specs — including the + retirement pins — are enforced by nothing. +3. **`as: "human"` is unvalidated.** An unvalidated caller string yields the + owner's caller-scope on `list_todos` / `resolve_todo`, terminal closure of + the owner's todos, and an `activity_events.actor='human'` stamp that + `project_progress` and `review_period` count as owner progress. "There is no + authorization layer" and "data scope is keyed on a caller-supplied string" + cannot both be acceptable. +4. **Publish authorization binds a row id, not bytes** (`admin.go:273-290`), + while the withdrawal receipt **is** byte-addressed (`004:37-38`). + +## 7. The loop-engineer thesis — corrected + +A second research pass (5 studies + 3 adversarial designs + a judge) examined +the loop-engineer framing directly. Its conclusions contradict the owner's +stated premise, and the contradiction is the useful part. + +### 7.1 The premise is backwards (VERIFIED against primary sources) + +> "if the flow is designed well, you don't even need a great model" + +- **Karpathy argues the opposite.** He attributes agent failure to model + cognition — intelligence, continual learning — not to process design. His own + loop artifact (`github.com/karpathy/autoresearch`) runs on a frontier model. +- **What autoresearch actually derives its power from:** one machine-computed + scalar (`val_bpb`); an evaluation harness the agent is **forbidden to + modify** (`prepare.py` — "Forbidden: modifications to evaluation logic"); git + keep-or-reset per experiment; an append-only `results.tsv`. The human writes + only `program.md`; the agent may edit only `train.py`. +- **Anthropic, fixed scaffold, three models on SWE-bench Verified:** 22% → 33% + → 49%. Twenty-seven points from swapping the model, zero scaffold change. + Their stated philosophy is "keep the scaffolding minimal" — two tools. +- **METR:** harness change moved the time horizon by *minutes*; model + generations moved it by *hours*. +- The genuine, measured value of harness engineering is **+4.7 to +7.7 points** + (Meta-Harness). The viral "same model, 42%→78% from a better harness" numbers + were traced to primary sources and are false or misread — one is dataset + cleaning that removed 68.3% of samples. +- **Reliability wall:** METR's 80%-success horizon is ~5× shorter than the 50% + horizon. At the current frontier that is roughly **one hour** of + human-equivalent work for something you would leave unattended — and every + one of those numbers was measured on software/ML/security tasks, the one + domain where a verifier exists. + +### 7.2 The real gate + +> **Does this domain have a fast, objective, verifier the agent cannot edit?** + +That single question explains everything observed: + +| Domain | Verifier | Loops? | +|---|---|---| +| koopa0.dev repo work | `go build / vet / lint / test` — already written | **Yes, today** | +| Publication | blob SHA + build + link check | **Yes** | +| Japanese, reading, Muay Thai | none | No — and a fake one is worse than none | + +His repo work flows because it has a verifier. His planning never did because it +has none. Anthropic's own SDK guidance ranks LLM-as-judge **last** among +verification methods — and a loop over goals/areas/projects is forced onto +exactly that weakest rung. That is a reason not to build it, not a reason to +build it better. + +### 7.3 The stuck todo already contains its own verifier (VERIFIED) + +The item `plan-day` has re-picked for 24 days reads: + +> Kotonoha 句層擴充:**phrases 35→~80** + +``` +$ grep -c "Phrase(" ~/flutter/japanese-learn/lib/domain/data/phrase_dataset.dart +35 +``` + +**The done-predicate is a one-line grep that returns exactly the number in the +todo's own title.** It has been machine-computable the entire 24 days. Nothing +was wired to it, so the only way the row could change state was the owner +clicking in admin — which is precisely the loop that never closed. + +This is the whole diagnosis in one command. It is not a PARA problem; PARA is +only where the button was placed. + +### 7.4 The judge rejected all three candidate designs + +Three independent designs (thin-spine / practice+session-log / reconciler) were +produced and adversarially judged. Verdict: **two are the same design in +different clothes**; the third depends on an input channel that has been silent +for 12 days; and **all three put a five-row scorecard in front of the owner with +four rows empty or wrong** — which is the exact mechanism of "I opened it, it +felt wrong, I stopped." + +Load-bearing verifications from that pass: + +- **mtime poisoning is already in the data.** `~/obsidian` last human commit is + `ef0ae54` (2026-07-11). Of 178 dirty files, **117 are a 2026-07-12/13 agent + style-rewrite batch** (+8,694 / −12,442). Any vault-mtime evidence channel + would show `japanese: fresh, 0d` on a day he did not study Japanese. The + false-green failure mode is not hypothetical; it would fire on day one. +- **The vault's human write channel has been empty for 12 days.** After 07-13 + only two files changed, both cron artifacts. +- **dev is the only live domain: 243 commits in 30 days**, two today. It is also + the one interface that has never felt wrong — and it has no scorecard. +- The `plan_day` DB-side claims (`completion_rate = 0`, `updated_at == + created_at`) remain **unverified** — the production DB is not reachable + locally. The 24-day identical output *is* verified, from cron output files. + +### 7.5 The recommended shape — smaller than all three, zero migration + +1. **No new tables. No deletions in phase 1.** Zero schema change. +2. **Disable, do not delete, the commitment half.** Turn off the `plan-day` + cron (it produced output again at 07:01 today) and drop the planning pages + from admin nav. One line to reverse. "Disable, observe, then delete" is the + owner's own convergent principle; all three designs skipped straight to + `DROP TABLE` on evidence that could not be re-derived. +3. **Wire one verifier.** No todo may enter `plan_day` unless a script can + decide it is done. Start with the Kotonoha grep (§7.3) into the existing + `resolve_todo`. This is a wrapper change, not a schema change. +4. **Remove the scorecard from the daily message.** Keep the existing 09:42 + `pa-brief` (zero new interfaces). Content becomes only: (a) what agents + actually finished yesterday, each with a clickable artifact, and (b) at most + three decisions that genuinely need him, each carrying a "do nothing" + default. No domain status, no dot matrix, no day counts. + **Test: the message must not contain a row about him that he would dispute.** +5. **Take exactly one idea from the reconciler design: typed decisions.** + Silence is a defined answer; the default is always the null action; a dedupe + key makes a repeated question a no-op; publish can never happen by timeout. + This is the only mechanism that addresses the per-item-approval friction — + it does not make approving faster, it makes **not approving safe**. Phase 1 + this can be a JSON file. +6. **Leave publishing alone.** `Writing/` has had no new drafts since 07-13; + optimising an empty pipeline is waste. + +**What it deliberately does not do:** track Japanese, reading, or Muay Thai +(no honest evidence channel exists, and a fake one is worse than silence); +answer "how far am I from the goal" (that needs a target field, and a target +field regrows PARA); provide any streak, stake, or penalty. + +### 7.6 The cheapest falsification — 5 minutes, before any DDL + +**Ask: "of the last seven 09:42 `pa-brief` messages, how many did you read?"** + +All three designs, and the recommendation above, hang the daily artifact on +`pa-brief` and assume he reads it. That assumption has never been tested and +testing it is free. If the answer is "none / no idea", redesigning the message +is pointless and no schema change matters. + +If it passes: one evening, a throwaway shell script over `git log` and +`find -newermt` renders two Telegram messages — (A) the five-row scorecard the +designs propose, (B) the no-scorecard version — and both get sent. The only +criterion is which one he does not want to close. What killed v1 was how the +screen felt, so that is the thing to test. + +## 8. Open — owner only + +1. **Which surfaces have you actually used?** Partially machine-answerable: + `activity_events` grouped by `actor` over 90 days, plus the count of + `contents` at `published`. An entity with zero `actor='human'` writes in 90 + days is a deletion candidate on your own evidence. (Limit: the triggers fire + on entity mutations, not route hits — it answers the *write* half only.) +2. **What is Whetstone?** `sync-vault-publish` has run 37 times publishing + Vault `status:ready` lessons to "Whetstone content/" — **not** koopa0.dev. + If that is your real publication pipeline, koopa's entire content surface + (the newest and most aligned code in the repo) may be redundant. +3. **Goal + Milestone: keep or drop?** (§4.1) +4. **Does the admin content editor's authoring path die?** This moves the + frontend decision more than anything else (§4.2). +5. **Is lock 4 satisfied by exposing receipt ingredients, or must Koopa own a + Vault writeback path?** No writeback exists today. +6. **Should renderer identity enter the publication contract** — a version + string, or a hash of the rendering code? (§4.2) +7. **`feat/mcp-triage-loop`** (unmerged, +1118, grows the catalog 14→16) adds + `list_inbox` + `triage_todo`. Does an agent triaging the inbox count as + expanding commitment (barred by O4)? Land, rework, or drop. +8. **Is the 6-stage adversarial-review protocol still your process?** If not, + `docs/audit-prompts/` + the skill retire together. diff --git a/docs/reviews/claude-design-notes-2026-07-10.md b/docs/reviews/claude-design-notes-2026-07-10.md new file mode 100644 index 000000000..46926d842 --- /dev/null +++ b/docs/reviews/claude-design-notes-2026-07-10.md @@ -0,0 +1,112 @@ +# Claude design notes — the agent operating model (2026-07-10) + +> Discussion artifact for the owner's question: "how do you (codex, claude +> code, hermes) use this system — beyond PARA, as more capabilities land — +> to track, complete, and automate my work, up to loop-engineering?" +> Status: proposal for owner gate + reconciliation with codex's alignment +> doc. Nothing here is scope until accepted. Nothing here blocks the +> current sprint (work-orders §4a wave 1). + +## 1. The operating model in one picture + +Three homes, one ledger, one gate. + +- **Homes** — each agent works where it lives: Claude Code in repos, + hermes in the vault, codex in repos (cross-review). Agents never write + into each other's homes (three-fence rule). +- **Ledger** — koopa0.dev is the shared commitment ledger. Anything + actionable, from ANY domain, lands in ONE place: the inbox. Projects / + goals / areas give it structure; brief and reports read it back; + activity_events remembers what happened. +- **Knowledge** — the vault holds what we know (lifecycle: + Inbox → Sources → Concepts → Synthesis → Writing). Never PARA-ized. +- **Gate** — the owner decides: triage verdicts (conversation), publish + (admin), merge (GitHub), activate (admin). Everything else is + asynchronous preparation or bookkeeping. + +The routing triangle already covers every domain the owner named: + +| Domain | Tracked by | Completed by | Evidence lands in | +|---|---|---|---| +| Project dev (countless repos) | per-repo work-orders doc + a koopa0.dev project row | Claude Code / codex sessions, PR flow | merged PRs, `/build-log`, (later PR-13 status notes) | +| Vault curation | hermes reports (additive, supersede) | hermes cron lanes | `System/reports/`, inbox captures | +| Studio (client delivery) | goal + /hire content pipeline | owner + Claude (content), agents (legwork) | published case studies / build-logs | +| Self-learning (日文, Go, literature) | recurring todos + goals/milestones | owner does the learning; agents prepare material (charter lanes) | habit occurrences (PR-6), vault notes | + +Claim type: this table is **Fact** about current design (each cell exists +today or is in the accepted queue), not new machinery. + +## 2. The gap the question actually exposes: an automation ladder + +"Track → complete → automate → loop-engineer" is a ladder, and the system +has rungs L0–L2 built but no written rule for CLIMBING. That rule is the +gatekeeping tool the owner asked for: + +- **L0 — owner-only judgment.** Publish, merge, activate, triage + verdicts, product semantics. Never automated. By design, forever. +- **L1 — agent-prepared, owner-decides.** Triage suggestions, digest + drafts, verdict recommendations, plan candidates. This is the current + frontier; G-1/W-12 is literally the experiment testing whether L1 + survives contact with the real owner. +- **L2 — autonomous bookkeeping with audit trail.** Backups, RSS + retention, report generation, recurring-occurrence tracking, CI gates. + Allowed where reversible + auditable; no owner presence needed. +- **L3 — loop-engineering.** The system observes its own usage and files + improvement proposals into its own inbox / review docs on a cadence. + Proposals only — the gate stays L0. + +**The climbing rule (proposed, this is the actual design):** every future +feature or automation candidate must (a) name its rung, and (b) climb +exactly one rung at a time, with usage evidence from the rung below — +never speculation. "It could be automated" is not evidence; N weeks of +L1 usage is. This generalizes G-1's logic from one bet to every future +capability, which is how the queue stops growing by review and starts +growing by demand. + +## 3. Loop-engineering, concretely + +Already exists (piecemeal): review_period (retro), W-8/W-12 (gates with +pinned criteria + tripwires), the adversarial-review protocol, /reflect, +/build-log. What's missing is only **cadence**: today loop-engineering +fires as crisis-driven mega-audits (five rounds in three days). Proposal: + +- **After the W-12 verdict** (not before — no new rituals mid-experiment): + make the audit an organ, not an event. Once per window (~monthly), a + scheduled self-audit session reads live usage data (brief, activity, + project_progress, published counts), compares against the north star, + and outputs ≤5 proposals into the owner's inbox + one review doc. + Cost class: prompt/skill-only. Zero backend. +- Verdict criteria for the ritual itself: if two consecutive self-audits + produce zero accepted proposals, kill the ritual (it must survive its + own ladder). + +## 4. Watchlist — name now, build only on evidence + +1. **Agent dev work is invisible to project momentum** (human-actor-only + counting, by design). Bridge today = /build-log + (conditional) PR-13. + If studio work grows, this pressure returns — revisit at W-12, not now. +2. **Multi-repo scaling**: the work-orders pattern (doc + locked decisions + + verifier acceptance) is portable to go-spec / kotonoha / hermes-rs by + copying the doc shape. When a second repo needs it, copy — do not build + a tool or a registry. +3. **Learning loop**: charter lanes already assign material generation + (hermes deterministic drills, Claude evergreen content). Execution + plane tracks only habits/goals; learning content stays in the vault. + No new feature needed — resist inventing one. +4. **ヨルシカ「歌與我」**: knowledge-plane work (vault Writing/), per owner + 2026-07-10 ruling. koopa0.dev is at most its eventual publish target. + +## 5. Explicitly NOT designed yet (deliberate) + +- Anything L3-autonomous that edits state (self-healing configs, auto- + triage): violates the judgment line. +- Cross-agent task hand-off (A2A): rejected by north star; owner routes. +- New tracking surfaces (dashboards, cockpits): anti-pattern on record. +- Angular→templ: deferred to post-W-12 by the verdict matrix. + +## 6. Sequencing + +Sprint (wave 1 + W-5) proceeds regardless of this note. Reconciliation +path: codex alignment doc lands → Claude reconciles it with this note → +merged proposal list → owner triage. The ladder rule (§2) is the one item +worth accepting early, because it disciplines every later decision. diff --git a/docs/reviews/codex-alignment-2026-07-10.md b/docs/reviews/codex-alignment-2026-07-10.md new file mode 100644 index 000000000..357edf41a --- /dev/null +++ b/docs/reviews/codex-alignment-2026-07-10.md @@ -0,0 +1,186 @@ +# Codex alignment check and independent brainstorm — 2026-07-10 + +> Scope: alignment with the six owner rulings recorded in +> `docs/work-orders-2026-07.md` §8, not acceptance that any work item is complete. +> `ALIGNED` means the ruling is proportionate to the evidence and has a credible +> falsification path; it does not erase the underlying finding. The brainstorm +> was composed before consulting anyone else's idea list. None of its items is +> scope unless the owner explicitly accepts it. + +## 1. Alignment check + +| Question | Owner ruling | Verdict | +|---|---|---| +| Q1 | Full core; protect the experiment backbone; commit pace now | **ALIGNED** | +| Q2 | Pace commitment plus W-5 latest-start 2026-07-17 | **ALIGNED** | +| Q3 | Install full W-1 with W-5 | **ALIGNED** | +| Q4 | Accept owner-presence as unobservable | **ALIGNED** | +| Q5 | Record the three publish-stage diagnostics at W-12 | **ALIGNED**, but the named data source is insufficient | +| Q6 | Defer archive cadence to W-1 install; accept the owner's counter | **DISAGREE** | + +### Q1 — FULL CORE + +**Verdict: ALIGNED. I accept the resolution, provisionally.** + +B-1 remains true: 28 unconditional core units are a large program, and a new +pace promise is not itself capacity evidence. What changes my decision is the +combination of three controls now attached to the promise: + +1. the experiment slice is explicitly the must-not-slip backbone; +2. W-5 has a near-term, absolute reopen marker rather than an indefinitely + unopened G-1; and +3. G-1's observation window starts as soon as W-5 runs, so later core work can + proceed without delaying the A/P experiment. + +I am accepting **a short, falsifiable capacity test before shrinking**, not the +claim that all 28 units have already earned near-term execution. “Best-practice +execution” must remain an acceptance bar for the written work orders, not a +license to expand them toward maximal convergence. + +**Evidence by 2026-07-24 that would change my mind back to EXPERIMENT SLICE:** + +- W-5 is not actually completed by 2026-07-17. Completion means the full inbox + was read, the owner issued real verdicts, and `triage_todo` state changes were + made; merging PR-5 alone does not count. +- W-1 delays W-5, or W-6 fails to run on the first Sunday after W-5 because + hygiene/core work took precedence. +- New units are added to the unconditional core before W-12 without removing or + deferring an existing unit. That would repeat the exact scope-growth pattern + B-1 identified. +- Later core work leapfrogs a blocked W-5, W-6, W-9, W-13, or W-1. The backbone + is meaningful only if it controls order under pressure. + +### Q2 — pace commitment plus latest-start + +**Verdict: ALIGNED.** + +This directly closes B-2. The 1–2 day pace target is useful intent; the +2026-07-17 marker is the actual governance mechanism. The latter detects the +observed failure mode—failure to launch—before the relative 28-day window can +hide it forever (`docs/work-orders-2026-07.md:82`). + +**Evidence that would change my mind:** if 2026-07-17 is rolled forward, treated +as a reminder, or satisfied by partial setup rather than the completed W-5 +conversation, this ruling becomes cosmetic. “Automatic reopen” must produce a +same-day queue-size decision, not another date. + +### Q3 — INSTRUMENT FIRST with full W-1 + +**Verdict: ALIGNED.** + +Installing the full contract is acceptable; a minimal instrument was a floor, +not a preferred deliverable. It gives criterion ③ the same observation window +as criteria ① and ② and avoids a second clock. + +There is one boundary to make literal. §4 writes `W-5 → W-1`, while W-12 says +W-1 is installed “同步” with W-5 (`docs/work-orders-2026-07.md:281,294`). For B-3 +to be closed, the canonical Cowork copy—not merely the repo draft—must be +installed no later than the recorded W-5 completion time. The first weekly +output should render the follow-up section even if its honest baseline is “no +prior recommendations.” + +**Evidence that would change my mind:** W-1 lands after the window opens, the +first weekly output omits the section, or polishing the full contract delays +W-5. In any of those cases, install the minimal section immediately and give +criterion ③ its own clock for the uncovered interval. + +### Q4 — ACCEPT UNOBSERVABLE + +**Verdict: ALIGNED.** + +B-4 was an honesty finding, not an argument for a new authorization layer. The +revised §7 now says exactly what the audit event can and cannot prove +(`docs/work-orders-2026-07.md:363-365`). In a closed roster, an +invocation-context field supplied by the same caller would add a claim, not an +enforcement boundary. Honest prose plus an explicit unattended-runner ban is +proportionate today. + +**Evidence that would change my mind:** one consequential triage/annotation +event that the owner disputes and the existing evidence cannot explain; an +unattended runner actually invoking either tool; or expansion beyond the closed +roster. The first real ambiguity is enough to reopen this—waiting for a pattern +would knowingly allow more unclassifiable mutations. + +### Q5 — lightest publish-stage tripwire + +**Verdict: ALIGNED with the decision; the recorded measurement source must be +corrected before B-5 is considered closed.** + +Review-queue count, oldest review age, and propose-to-publish latency are the +minimum useful split between “agents produced nothing” and “finished work +waited for the owner.” A threshold is not necessary for the first 28-day +diagnostic; the stage values are enough to assign the failure before choosing a +remedy. + +The tracker currently says all three come from `brief.content_pipeline` +(`docs/work-orders-2026-07.md:281`). Current code cannot reliably supply any of +the three as promised: + +- `brief.content_pipeline` contains only `id`, `title`, `type`, `status`, and + `updated_at`; it reads at most 20 review rows ordered newest-first, so it has + neither an exact queue count nor the true oldest row + (`internal/mcp/brief.go:205-213,398-410`; + `internal/content/query.sql:289-297`); +- `review_period.published_content` contains `title`, `type`, and + `published_at`, but not content ID or proposal time + (`internal/mcp/review.go:78-83,192-201`). + +The lightest truthful source already exists: join the content `created` event +whose payload says `status=review` to the same entity's `published` event in +`activity_events`. Both timestamps and the entity ID are recorded by the +existing trigger (`migrations/001_initial.up.sql:981-1006`). Queue count and +oldest pending age can still come from current review rows. This is a query and +contract correction, not a new mechanism. + +**Evidence that would change my mind:** if the W-6 proposal cannot be used to +rehearse all three calculations before 2026-07-24, the tripwire is not installed +and B-5 remains open. If a review item survives two owner check-ins, a W-12-only +snapshot is also too late; promote oldest-review age to a conditional weekly +alert. + +### Q6 — report archive cadence + +**Verdict: DISAGREE.** + +I accept that an incomplete system is a plausible reason earlier reports went +unread. I also accept the owner-reported July article as positive evidence for +bet P. Neither fact answers B-6: whether seven durable daily archives per week +serve a twice-weekly owner or any downstream agent. Publication validates the +publishing loop, not report archival cadence. + +Deferring the choice for the few days until W-1 install is harmless. Treating +that deferral or the counterargument as resolution is not. Daily freshness and +durable cadence are separate knobs. My default remains: refresh `latest.html` +daily if useful, but durably archive the weekly report and the actual editorial +check-ins until daily archives demonstrate a consumer. + +**Evidence by 2026-07-24 that would change my mind:** at least two non-weekly, +non-check-in daily archives are later cited by the owner, a report, or an agent +to make a decision or reconstruct provenance; or the owner explicitly declares +the daily archive an intentional historical product and accepts its retrieval +clutter independent of reads. Mere generation, and the July article, do not +meet that test. + +## 2. Independent brainstorm + +These ideas use the observed split—daily agents prepare; a twice-weekly owner +judges—and the existing A/P bets. They do not reopen any §1 lock, move publish +into conversation, add a content type, add a cockpit, or touch the +Angular-to-templ question. + +| # | One-line design | Bet | Cost class | Cheapest falsifiable first version | +|---|---|---|---|---| +| 1 | **Six-minute editorial check-in:** a reusable skill opens each owner visit with at most three judgment calls plus one oldest review item; triage executes in conversation, while the content item deep-links to the existing admin editor/publish gate. | A + P | prompt/skill-only | Run it manually at the next two owner visits. Keep it only if every surfaced decision gets an explicit verdict with at most one clarification and at least one content item receives a publish/send-back disposition across the two visits; kill it if it becomes another recap. | +| 2 | **Decision-ready capture envelope:** every daily agent writes an actionable capture as four compact fields—evidence, recommendation, exact owner judgment requested, and consequence of deferral—inside the existing description. | A | prompt/skill-only | Apply the template to one agent's next 10 captures. It passes only if the owner can triage at least 8 without a follow-up question; otherwise simplify or discard the envelope rather than adding schema. | +| 3 | **Publish-readiness receipt:** use the existing `proposal_rationale` to attach a strict preflight—target reader/hook, source evidence, privacy exclusions, link check, and “why now”—so the owner judges rather than repairs. | P | prompt/skill-only | Put the receipt on the next two proposals. It fails if both still need substantive owner editing or missing-context questions; no new field or UI until the receipt proves useful. | +| 4 | **Editorial learning receipt:** after publish or send-back, return to the proposing agent a compact summary of the owner's substantive edits and reason, so daily producers can improve instead of seeing only a terminal status. | P | new mechanism | Before building anything, preserve and manually diff the next two proposals from the same agent against their final disposition, then feed back a five-line lesson. Build persistence/readback only if the second draft requires materially fewer corrections. | +| 5 | **Verifier-to-story seed:** extend W-13's raw build log with a public-safe five-part seed—problem, failure mode, decision, proof, reusable lesson—derived only from committed and independently verified work. | P | prompt/skill-only | Generate seeds for the next three verified PRs. Promote the idea only if one seed becomes a finished existing-type draft in at most one focused hour without inventing missing context; otherwise raw dev work is not yet cheap publishing supply. | +| 6 | **WILD — proof blocks for case studies:** let an existing article/build-log embed compact `claim → failure → invariant → test → result` blocks backed by verifier evidence, turning engineering discipline into visible client-facing proof. | P | frontend-only | Prototype one block in plain article markup before creating a component. Show the plain and proof-block versions to three target readers; proceed only if at least two prefer the proof version and can accurately restate what was proven. | + +### Gate recommendation + +Do not accept all six. The cheapest first gate is ideas 1–3 as manual prompt +experiments; idea 5 can ride the already-approved W-13 trial without becoming a +new work unit. Ideas 4 and 6 should remain parked until their manual versions +produce evidence. That preserves the purpose of this list: test leverage, not +grow the 34-unit queue. diff --git a/docs/reviews/codex-review-2026-07-10.md b/docs/reviews/codex-review-2026-07-10.md new file mode 100644 index 000000000..9eefe0de5 --- /dev/null +++ b/docs/reviews/codex-review-2026-07-10.md @@ -0,0 +1,219 @@ +# Codex adversarial review — 2026-07-10 + +> Task type: audit. The only file produced by this pass is this review. +> Evidence labels are **Fact**, **Inference**, and **needs-human**. Current code is +> descriptive authority; `docs/work-orders-2026-07.md` is the intended target. +> The Angular-to-templ question is deliberately excluded. Findings already made +> in `third-party-review-2026-07-06.md` are not repeated; references to their +> accepted remediations are context, not new findings. + +## A. Assessment + +### A-1. The system is an execution and editorial control plane, not a general knowledge engine + +**Fact.** The July north star assigns current commitments to koopa0.dev and +private knowledge to the Obsidian lifecycle vault; the two are joined in an +agent context window, with no synchronization or ingestion layer +(`docs/work-orders-2026-07.md:15-32`). The code exposes a closed four-identity +roster (`internal/agent/registry.go:17-48`) and a canonical 15-tool MCP surface +(`internal/mcp/ops/catalog.go:235-256`). Activation and publication remain +owner-only admin actions (`CLAUDE.md:148-155`). + +**Inference.** The problem this system actually solves is: multiple agents need +a durable, attributable place to leave execution candidates and finished +editorial work while one human retains commitment and publication authority. +Search, PARA, and the public site support that loop; they are not the product's +center. This is a better description of observed use than "personal knowledge +engine." + +### A-2. The architecture is appropriately asymmetric for its two real user classes + +**Fact.** Agents receive cheap read models plus narrow writes: the current +catalog has six read-only tools, five additive tools, one idempotent tool, and +three destructive caller-scoped tools (`internal/mcp/ops/catalog.go:26-233`). +The target plan removes the failed daily-plan question, adds an owner-decision +triage loop, keeps publishing off MCP, and refuses a new cockpit or content type +(`docs/work-orders-2026-07.md:120-150,192-197,402-411`). + +**Inference.** This is the right architectural shape for daily agent users and +a twice-weekly editor-in-chief: agents can prepare and record; the owner decides +only where judgment is irreducible. The dual-plane boundary also avoids the +anti-pattern of rebuilding the vault inside PostgreSQL. The architecture is not +the main risk now. + +### A-3. The intended UX is stronger than the current UX, but it is still only a plan + +**Fact.** Current code still registers `plan_day` and `search_knowledge`, and +does not register `list_inbox` or `triage_todo` +(`internal/mcp/ops/catalog.go:71-80,42-52,239-256`). The work-order completion +records are blank (`docs/work-orders-2026-07.md:98-262`), and the latest tracked +commit reproduced on 2026-07-10 is `6f32b6f2` from 2026-07-03. + +**Inference.** For the owner, "conversation + reports" is not yet an operating +console; for agents, the cheap cross-agent triage read/write path is not yet an +available daily surface. The plan should therefore be judged first as an +execution experiment, not as a delivered redesign. Its largest remaining risk +is whether the owner can get a small experiment running at all. + +### A-4. Overall verdict + +**Inference.** Product direction: sound. Feature restraint: materially improved. +Architecture: coherent for one owner and a closed agent roster. Usability: +promising for both user classes once the target surface exists. Execution +policy: still mismatched to the observed implementer. The next success criterion +should be a running A/P experiment, not backlog throughput. + +## B. Blind spots + +### B-1. The two-tier correction still leaves almost the entire program unconditional + +**Fact.** The tracker now contains 20 PR headings and 13 W rows; PR-19a and +PR-19b are explicitly separate PRs. Reproduction on 2026-07-10: + +```text +PR headings=20 +W rows=13 +heading total=33 +execution units including PR-19a+19b=34 +``` + +The document still describes the old total as 31 +(`docs/work-orders-2026-07.md:288-290,390-399`). Counting the authoritative +execution policy yields 28 core units when W-2 and W-3 are counted separately, +plus two fixed-time judgments; only four units are conditional on G-1 +(`docs/work-orders-2026-07.md:291-317`). + +The first-wave code/ops artifacts are also still absent: the requested backup +script does not exist; deploy remains a separate push-triggered workflow +(`.github/workflows/deploy.yml:1-16`); the MCP service still lacks +`GEMINI_API_KEY` (`docker-compose.yml:105-115`); and the catalog still lacks +PR-5's two tools (`internal/mcp/ops/catalog.go:235-256`). External-only W items +cannot be verified from this repo and are **needs-human**. + +**Inference.** The fourth-round capacity correction changed labels more than +load. Thirty of 34 units survive regardless of G-1, despite the live evidence +that the first item did not start. This is still a comprehensive program for an +ideal implementer, not a bounded work queue for the observed owner. The stale +31 count matters because it conceals that two later review rounds increased, +rather than reduced, the execution surface. + +### B-2. G-1 cannot detect failure to start + +**Fact.** G-1's clock starts only when W-5 completes +(`docs/work-orders-2026-07.md:82,281`). W-5 itself is downstream of PR-0, +PR-1, PR-19a, and PR-5 in the first wave +(`docs/work-orders-2026-07.md:291-299`). The tracker defines no calendar +deadline or alternate verdict for "W-5 never happened." + +**Inference.** The stop-loss measures adoption after the experiment launches, +but the observed failure mode is inability to launch. If the queue remains +untouched, G-1 remains pending forever and never triggers shrinkage. A relative +28-day window is not an execution tripwire without an absolute latest-start +date. + +### B-3. One G-1 criterion is scheduled to be instrumented after its observation window opens + +**Fact.** Criterion ③ depends on the weekly report's fixed "recommendation +follow-up" section (`docs/work-orders-2026-07.md:270-281`). The G-1 window opens +at W-5, but W-1 is scheduled only in the third wave, after the entire second +wave; W-6 is deliberately run immediately after W-5 without W-1 +(`docs/work-orders-2026-07.md:275,294-299`). The draft report contract confirms +that this follow-up section is the reporting instrument +(`docs/agents/report-contract-v2.md:48-57`). + +**Inference.** Criterion ③ may receive less than 28 days of observation, or no +observations at all, while criteria ① and ② get the full window. A two-of-three +gate with unequal instrumentation periods can pass or fail because of work-order +timing rather than owner behavior. The metric must exist when the window opens, +or its clock must start separately. + +### B-4. The audit trail cannot prove the owner-present condition it is supposed to police + +**Fact.** PR-5 says `triage_todo` may run only in an owner-present conversation, +and §7 says misuse will be visible afterward through the audit trail +(`docs/work-orders-2026-07.md:139-150,363-366`). The semantic contract states +that an MCP call carries no cron-versus-chat signal +(`docs/para-semantic-contract.md:69-71`). `activity_events` records entity, +change, actor, timestamp, and a change-specific payload +(`migrations/001_initial.up.sql:789-847`); the todo trigger payload records only +state or the state transition (`migrations/001_initial.up.sql:890-904`). + +**Inference.** The audit trail can prove that `claude` or `hermes` changed a +todo, but it cannot prove whether the owner was present and authorized that +change. This does not reopen Option B or demand a hard authorization gate. It +does mean the §7 claim "misuse is visible" is too strong unless invocation +context is recorded elsewhere. Today the policy violation is unobservable for +the same actor using the same tool in two contexts. + +### B-5. The publish-bottleneck risk has an outcome threshold but no diagnostic tripwire + +**Fact.** §7 says to revisit publishing if the admin-only publish step becomes a +bottleneck (`docs/work-orders-2026-07.md:363-371`). W-12 and the report KPI pin +only final published counts (`docs/work-orders-2026-07.md:281-282`; +`docs/agents/report-contract-v2.md:35-38`). Current `brief` already exposes each +draft/review row's status and `updated_at` +(`internal/mcp/brief.go:205-213,398-410`), but the work order specifies no +threshold for review-queue age, queue growth, or proposal-to-publish latency. + +**Inference.** A failed P signal cannot distinguish "no material was produced" +from "finished material waited in admin for the owner." Those imply different +actions, but the verdict matrix observes only the shared zero-output result. +The necessary stage data already exists; what is missing is a pinned diagnostic +rule. + +### B-6. Daily report production is not yet justified by a twice-weekly owner + +**Fact.** The north star says the owner appears twice a week +(`docs/work-orders-2026-07.md:24-30`). The report contract nevertheless defines +a daily report plus a weekly report, and archives every daily report while also +overwriting `latest.html` (`docs/agents/report-contract-v2.md:8-16`). No work +item measures daily-report reads; W-12 measures weekly recommendation responses, +not daily attendance (`docs/work-orders-2026-07.md:270-281`). + +**Inference.** Refreshing `latest.html` daily can serve agents and give the +owner a fresh page whenever he arrives. Archiving a daily owner-facing report is +a different commitment: it creates seven artifacts for a user observed twice a +week. That may be harmless automation, but it is not yet designed from observed +attendance. This is **needs-human**, not a defect verdict: the owner must choose +whether freshness or report cadence is the real requirement. + +## C. Questions for the owner + +1. **Capacity verdict — `EXPERIMENT SLICE` or `FULL CORE`?** Should the + unconditional pre-observation queue be reduced to the items that make the A/P + experiment runnable (PR-0, PR-1, PR-19a, PR-5, W-5, W-6, W-9, W-13), with + the minimal W-1 instrument chosen in question 3 and the remaining core parked + until a first readout; or do all 28 core units remain mandatory before the + readout, with the two fixed-time judgments still on schedule? Grounding: + `docs/work-orders-2026-07.md:291-317`. + +2. **Latest-start verdict — `2026-07-17` or `NO DEADLINE`?** If W-5 has not + happened by 2026-07-17, should that count as an execution-gate failure and + force a smaller queue, or may G-1 remain unopened indefinitely? Grounding: + `docs/work-orders-2026-07.md:82,281,291-299`. + +3. **Report-metric verdict — `INSTRUMENT FIRST` or `SEPARATE CLOCK`?** Should a + minimal W-1 recommendation-follow-up section move before W-5, or should G-1 + criterion ③ receive its own 28-day window starting when W-1 is installed? + Grounding: `docs/work-orders-2026-07.md:270-281,294-299`. + +4. **Owner-presence verdict — `ACCEPT UNOBSERVABLE` or `RECORD CONTEXT`?** Is + owner-present a policy whose violations are knowingly not distinguishable in + koopa0.dev, or must the caller/runner record an invocation context alongside + the audit event? This is an observability choice, not a request to reverse + Option B. Grounding: `docs/work-orders-2026-07.md:139-150,363-366`; + `docs/para-semantic-contract.md:69-71`. + +5. **Publish diagnosis verdict — `ADD STAGE TRIPWIRE` or `OUTCOME ONLY`?** Should + W-12 record review-queue count, oldest review age, and proposal-to-publish + latency so a P failure can be assigned to supply versus admin publishing, or + is final published count intentionally sufficient? Grounding: + `docs/work-orders-2026-07.md:281-282,363-371`; + `internal/mcp/brief.go:205-213,398-410`. + +6. **Report cadence verdict — `DAILY LATEST / TWICE-WEEKLY ARCHIVE` or + `DAILY ARCHIVE`?** Should automation keep `latest.html` fresh daily but create + durable reports only at the owner's two editorial check-ins, or is a + seven-per-week archive an intentional historical product? Grounding: + `docs/work-orders-2026-07.md:24-30`; + `docs/agents/report-contract-v2.md:8-16`. diff --git a/docs/reviews/pr-0-owner-execution-runbook-2026-07-10.md b/docs/reviews/pr-0-owner-execution-runbook-2026-07-10.md new file mode 100644 index 000000000..062e067ce --- /dev/null +++ b/docs/reviews/pr-0-owner-execution-runbook-2026-07-10.md @@ -0,0 +1,228 @@ +# PR-0 owner execution runbook — E1 to E6 + +> Acceptance handoff, 2026-07-10. This note is intentionally uncommitted unless +> the owner later assigns it to a docs disposition. It does not certify PR-0. + +## One-screen order + +| Order | Gate | Executor / location | Mutates production? | Stop condition / evidence | +|---|---|---|---|---| +| 1 | E1 backup is running | Claude Code on the VPS | No | Paste all four probe outputs, then STOP | +| 2 | E2 bucket is private | Owner in Cloudflare Dashboard | No | Paste screenshots or API output | +| 3 | E3 dump restores completely | Claude Code on the Mac, using local Docker | No production DB write | Paste the single drill transcript | +| 4 | E4 demote 16 goals | Claude Code on the VPS, production PostgreSQL | **Yes** | One execution only; paste full `psql` output | +| 5 | E5 mount four projects | Claude Code on the VPS, production PostgreSQL | **Yes** | Paste full `psql` output | +| 6 | E6 rate-limit rule | Owner in Cloudflare Dashboard | **Yes, Cloudflare config** | Paste screenshot / rule summary | +| 7 | SSOT update | Coordination session on the Mac | Repo write only | Commit amended work order; no completion verdict yet | + +Do not start E4 until E1, E2, and E3 evidence has been reviewed. Any unexpected +output or non-zero exit means **STOP**: report it unchanged; do not diagnose by +editing production data and do not retry E4. + +## Prompt to give the VPS Claude Code session now + +```text +You are the bounded PR-0 production executor. Do only the steps explicitly +authorized by the owner. First run E1 (read-only), return the complete output, +then STOP. Do not run E4 or E5 until the owner sends an explicit GO after E1, +E2, and E3 have been reviewed. + +When later authorized for E4/E5: +- use only the supplied, hash-checked SQL files; +- run E4 exactly once, then E5; +- preserve complete stdout/stderr and exit codes; +- on any ERROR or non-zero exit, STOP and report it; do not retry or fix inline; +- do not edit the tracker, open a PR, or claim acceptance PASS. +``` + +## E1 — VPS, read-only backup evidence + +Run on the VPS. Do not enable shell tracing: the environment file contains +credentials. + +```bash +set +x + +echo '=== E1.1 installed cron ===' +crontab -l | grep backup-db-r2 + +echo '=== E1.2 recent local koopa0dev dumps ===' +ls -lh ~/backups/db/koopa0dev-* | tail -3 + +echo '=== E1.3 recent off-site koopa0dev objects ===' +set -a +. ~/koopa0.dev/.env +set +a +export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID" +export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY" +~/.local/bin/aws s3 ls s3://koopa0-dev/backups/koopa0dev-db/ \ + --endpoint-url "$R2_ENDPOINT" | tail -3 + +echo '=== E1.4 freshness metric ===' +cat /var/lib/node-exporter-textfile/db_backup_koopa0dev.prom +``` + +Required raw evidence: + +- cron contains the committed `backup-db-r2` job; +- recent local dump names and timestamps; +- a current off-site object under `backups/koopa0dev-db/`; +- the complete freshness metric line. + +After printing these four blocks, the VPS session must STOP and return them. + +## E2 — owner, Cloudflare Dashboard + +This is not a VPS command. + +Open Cloudflare → R2 → `koopa0-dev` → Settings → Public Access and capture: + +1. Custom Domains: none assigned. +2. Public Development URL / `r2.dev`: disabled. + +Return screenshots or equivalent Cloudflare API output. Do not infer runtime +state from Terraform alone. + +## E3 — Mac, isolated restore drill + +This is not a production-VPS database command. Run it on the Mac with Docker, +using a newly downloaded **temporary copy** of the R2 dump. The script deletes +the dump path passed to it on every exit, so never give it a canonical backup +path. + +```bash +SCRATCH=/private/tmp/claude-501/-Users-koopa-koopa0-dev/538da6f3-aa23-430c-b217-8c555cb898b2/scratchpad +DUMP=/tmp/koopa0dev-YYYYMMDD-HHMMSS.sql.gz + +bash "$SCRATCH/e3-drill.sh" "$DUMP" +e3_rc=$? +echo "E3_EXIT=$e3_rc" +``` + +Expected evidence: + +```text +restore exit 0 · stderr empty · 18/18 tables matched · 5 triggers +DRILL PASS +E3_EXIT=0 +``` + +Anything else is a failure. Paste the full single-run output; do not rerun merely +to recover a lost exit code. + +## Transfer the accepted E4/E5 SQL to the VPS + +Only do this after E1–E3 have been reviewed and the owner says GO. + +From the Mac: + +```bash +SCRATCH=/private/tmp/claude-501/-Users-koopa-koopa0-dev/538da6f3-aa23-430c-b217-8c555cb898b2/scratchpad +scp "$SCRATCH/e4.sql" koopa@<VPS_HOST>:/tmp/pr0-e4.sql +scp "$SCRATCH/e5.sql" koopa@<VPS_HOST>:/tmp/pr0-e5.sql +``` + +On the VPS, verify the transferred files before execution: + +```bash +sha256sum /tmp/pr0-e4.sql /tmp/pr0-e5.sql +``` + +Required hashes: + +```text +58dc83e5148477cbc5bf0113a7e1e795063e49d156a4517d8780ab9842d13d13 /tmp/pr0-e4.sql +1ef13a96bee5c80324dc3c48febd06a0f3e01409d96bb70077c1f066bc220071 /tmp/pr0-e5.sql +``` + +If either hash differs, STOP. Do not repair or retype the SQL on the VPS. + +## E4 — VPS production PostgreSQL, one execution only + +E4 intentionally rejects a second execution. Run it once and preserve that +single transcript. + +```bash +cd ~/server +docker compose ps postgres +docker compose exec -T postgres \ + psql -X -U koopa -d koopa0dev < /tmp/pr0-e4.sql 2>&1 +e4_rc=$? +echo "E4_EXIT=$e4_rc" +``` + +Expected tail: + +```text +NOTICE: E4 OK — 16 goals demoted; 16 audit rows, all actor=claude, across 6 areas; 5 in_progress remain +DO +COMMIT +E4_EXIT=0 +``` + +On `ERROR`, missing `COMMIT`, or non-zero exit: STOP. Do not rerun E4. + +## E5 — VPS production PostgreSQL + +Run only after E4 returned the expected success transcript. + +```bash +cd ~/server +docker compose exec -T postgres \ + psql -X -U koopa -d koopa0dev < /tmp/pr0-e5.sql 2>&1 +e5_rc=$? +echo "E5_EXIT=$e5_rc" +``` + +Expected tail: + +```text +NOTICE: E5 OK — 2 projects mounted to goals, 2 area-only; zero audit rows written +DO +COMMIT +E5_EXIT=0 +``` + +On `ERROR`, missing `COMMIT`, or non-zero exit: STOP. Do not change mappings +inline. E5 is idempotent, but there is still no reason to rerun a successful +production operation. + +After E4/E5, return both complete transcripts. The acceptance session will run +the read-only MCP postconditions; the VPS executor does not self-certify them. + +## E6 — owner, Cloudflare Dashboard + +This is not a VPS command. + +Cloudflare → Security → WAF → Rate limiting rules → Create: + +- condition: URI Path starts with `/api/search`; +- threshold: 10 requests per 10 seconds; +- characteristic: per IP; +- action: Block. + +Return a screenshot or rule summary containing condition, threshold, +characteristic, action, and enabled state. + +## Coordination after E6 + +After E1–E6 evidence is collected, the coordination session applies +`ssot-amendment-DRAFT-v3.md` to `docs/work-orders-2026-07.md` and commits it. +That session must not fill PR-0's completion record or declare PASS. The new +committed HEAD returns to the acceptance session first. + +No PR is opened yet. Only after independent acceptance passes may the owner open +the PR, wait for Augment Code review, and then decide whether to merge. + +## Evidence return template + +```text +E1: full output of probes 1–4 +E2: screenshots/API output +E3: full one-run output + E3_EXIT +E4: sha256 + full psql output + E4_EXIT +E5: sha256 + full psql output + E5_EXIT +E6: screenshot/rule summary +Unexpected output: none / paste verbatim +Production fixes attempted outside the runbook: none +``` diff --git a/frontend/.gitignore b/frontend/.gitignore index 55940b5bb..b3f77c6d3 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -45,3 +45,8 @@ e2e/report/ .DS_Store Thumbs.db .claude/session-learnings.log + +# Generated by cmd/publish from content/. Derived, never edited by hand. +/public/content/ +/public/sitemap.xml +/public/feed.xml diff --git a/frontend/angular.json b/frontend/angular.json index 0b0231d2c..c13db76d5 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -18,12 +18,9 @@ "builder": "@angular/build:application", "options": { "outputPath": "dist/koopa0dev", - "outputMode": "server", + "outputMode": "static", "browser": "src/main.ts", "server": "src/main.server.ts", - "ssr": { - "entry": "server.ts" - }, "security": { "allowedHosts": [ "localhost", @@ -127,4 +124,4 @@ "angular-eslint" ] } -} +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 4c4dfc888..0ae6ed562 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,6 @@ "watch": "ng build --watch --configuration development", "test": "ng test", "test:watch": "ng test --watch", - "test:syndication": "node --test tests/syndication-contract.test.mjs", "lint": "ng lint", "e2e": "npx playwright test", "check:quality": "bash scripts/check-frontend-quality.sh" @@ -59,4 +58,4 @@ "typescript-eslint": "^8.60.1", "vitest": "^4.0.18" } -} +} \ No newline at end of file diff --git a/frontend/public/content/articles/ai-assisted-programming-guide.md b/frontend/public/content/articles/ai-assisted-programming-guide.md deleted file mode 100644 index f264ec67d..000000000 --- a/frontend/public/content/articles/ai-assisted-programming-guide.md +++ /dev/null @@ -1,194 +0,0 @@ -# AI 輔助程式開發:ChatGPT 與 GitHub Copilot 實戰指南 - -AI 工具正在徹底改變軟體開發的方式,從程式碼生成到程式碼審查,AI 已經成為開發者不可或缺的助手。 - -## GitHub Copilot 實戰技巧 - -### 基本使用 - -GitHub Copilot 可以根據註解和程式碼上下文生成程式碼: - -```typescript -// 建立一個函數來計算兩個日期之間的天數差 -function daysBetween(date1: Date, date2: Date): number { - const timeDiff = Math.abs(date2.getTime() - date1.getTime()); - return Math.ceil(timeDiff / (1000 * 3600 * 24)); -} -``` - -### 進階應用 - -```typescript -// 建立一個 React Hook 用於處理 API 請求狀態 -function useApiRequest<T>(url: string) { - const [data, setData] = useState<T | null>(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState<string | null>(null); - - const fetchData = useCallback(async () => { - setLoading(true); - setError(null); - - try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const result = await response.json(); - setData(result); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }, [url]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; -} -``` - -## ChatGPT 開發工作流程 - -### 程式碼審查 - -ChatGPT 可以幫助進行程式碼審查: - -**提示詞範例:** -"請審查以下 TypeScript 程式碼,關注效能、安全性和最佳實踐: - -```typescript -[你的程式碼] -``` - -請提供具體的改進建議。" - -### 程式碼重構 - -**提示詞範例:** -"請幫我重構以下程式碼,使其更符合 SOLID 原則並提高可測試性: - -```typescript -[需要重構的程式碼] -```" - -### 程式碼解釋 - -**提示詞範例:** -"請詳細解釋以下演算法的工作原理,包括時間和空間複雜度: - -```python -[複雜的演算法程式碼] -```" - -## AI 工具比較 - -### GitHub Copilot -**優勢:** -- 深度整合 IDE -- 優秀的上下文理解 -- 即時程式碼建議 - -**限制:** -- 需要付費訂閱 -- 可能產生有版權問題的程式碼 - -### ChatGPT -**優勢:** -- 詳細的解釋和教學 -- 支援多輪對話 -- 可處理複雜的架構問題 - -**限制:** -- 需要切換上下文 -- 可能產生過時的資訊 - -### Claude -**優勢:** -- 較長的上下文窗口 -- 優秀的程式碼分析能力 -- 良好的安全性考量 - -**限制:** -- 可用性因地區而異 -- 較新的工具,生態系統尚在發展 - -## 最佳實踐 - -### 提示工程 - -1. **提供充分的上下文** -``` -// 好的提示 -"我正在使用 React 18 和 TypeScript 開發一個電商網站。 -請幫我建立一個購物車 Hook,需要支援: -- 添加/移除商品 -- 更新數量 -- 計算總價 -- 持久化到 localStorage" - -// 不好的提示 -"幫我寫一個購物車" -``` - -2. **指定程式語言和框架** -3. **說明特定需求和限制** -4. **要求解釋和註解** - -### 程式碼驗證 - -1. **總是檢查生成的程式碼** -2. **運行測試確保正確性** -3. **檢查安全性問題** -4. **驗證效能影響** - -### 學習增強 - -1. **理解 AI 生成的程式碼** -2. **學習新的模式和技巧** -3. **保持對新技術的敏感度** - -## 實際工作流程範例 - -### 1. 需求分析階段 -使用 ChatGPT 進行需求梳理和技術方案設計 - -### 2. 程式碼開發階段 -使用 GitHub Copilot 進行快速程式碼生成 - -### 3. 程式碼審查階段 -使用 ChatGPT 進行程式碼審查和重構建議 - -### 4. 調試階段 -使用 AI 工具分析錯誤和提供解決方案 - -### 5. 文件撰寫階段 -使用 AI 工具生成程式碼文件和 README - -## 注意事項 - -### 法律和倫理考量 -1. **檢查程式碼的版權問題** -2. **避免洩露敏感資訊** -3. **遵守公司的 AI 使用政策** - -### 技術考量 -1. **驗證程式碼的正確性** -2. **考慮程式碼的維護性** -3. **評估效能影響** - -## 未來展望 - -AI 輔助程式開發將持續演進: - -1. **更智能的程式碼生成** -2. **更好的程式碼理解能力** -3. **整合的開發環境** -4. **自動化測試生成** - -## 總結 - -AI 工具已經成為現代軟體開發不可或缺的一部分。合理使用這些工具可以顯著提升開發效率和程式碼品質。關鍵是要保持批判性思維,將 AI 視為助手而非替代品。 diff --git a/frontend/public/content/articles/angular-signals-complete-guide.md b/frontend/public/content/articles/angular-signals-complete-guide.md deleted file mode 100644 index 2cd4b26d7..000000000 --- a/frontend/public/content/articles/angular-signals-complete-guide.md +++ /dev/null @@ -1,55 +0,0 @@ -# Angular Signals: 完整指南與最佳實踐 - -## 什麼是 Angular Signals? - -Angular Signals 是 Angular 20+ 中引入的新響應式編程范式,它提供了一種更簡潔、更高效的狀態管理方式。 - -## 基本用法 - -```typescript -import { signal, computed, effect } from '@angular/core'; - -// 創建一個信號 -const count = signal(0); - -// 讀取信號值 -console.log(count()); // 0 - -// 更新信號值 -count.set(10); -count.update(value => value + 1); -``` - -## 計算信號 (Computed Signals) - -```typescript -const count = signal(0); -const doubledCount = computed(() => count() * 2); - -console.log(doubledCount()); // 0 -count.set(5); -console.log(doubledCount()); // 10 -``` - -## 效果 (Effects) - -```typescript -const count = signal(0); - -effect(() => { - console.log('計數變更:', count()); -}); - -count.set(5); // 輸出: "計數變更: 5" -``` - -## 最佳實踐 - -1. **使用 Signals 進行狀態管理** -2. **避免在迴圈中創建 Signals** -3. **合理使用 Effects** -4. **保持 OnPush 變更檢測策略** - -## 總結 - -Angular Signals 為現代 Angular 應用提供了強大而高效的響應式編程能力。 diff --git a/frontend/public/content/articles/flutter-state-management-riverpod-bloc.md b/frontend/public/content/articles/flutter-state-management-riverpod-bloc.md deleted file mode 100644 index 273c8f678..000000000 --- a/frontend/public/content/articles/flutter-state-management-riverpod-bloc.md +++ /dev/null @@ -1,154 +0,0 @@ -# Flutter 狀態管理:Riverpod vs Bloc 完整比較 - -## 狀態管理的重要性 - -在 Flutter 應用開發中,選擇合適的狀態管理方案對應用的可維護性和性能至關重要。 - -## Riverpod 簡介 - -Riverpod 是 Provider 的重新設計版本,提供了更安全、更靈活的狀態管理方案。 - -### Riverpod 基本用法 - -```dart -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -// 定義 Provider -final counterProvider = StateProvider<int>((ref) => 0); - -// 在 Widget 中使用 -class CounterWidget extends ConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - final count = ref.watch(counterProvider); - - return Column( - children: [ - Text('Count: $count'), - ElevatedButton( - onPressed: () => ref.read(counterProvider.notifier).state++, - child: Text('Increment'), - ), - ], - ); - } -} -``` - -### Riverpod 進階用法 - -```dart -// AsyncProvider 處理異步數據 -final userProvider = FutureProvider<User>((ref) async { - final api = ref.read(apiProvider); - return api.fetchUser(); -}); - -// 組合 Provider -final filteredTodosProvider = Provider<List<Todo>>((ref) { - final todos = ref.watch(todosProvider); - final filter = ref.watch(filterProvider); - - return todos.where((todo) => filter.apply(todo)).toList(); -}); -``` - -## Bloc 簡介 - -Bloc (Business Logic Component) 基於流和響應式編程,提供了清晰的狀態管理架構。 - -### Bloc 基本用法 - -```dart -// 定義事件 -abstract class CounterEvent {} -class Increment extends CounterEvent {} -class Decrement extends CounterEvent {} - -// 定義 Bloc -class CounterBloc extends Bloc<CounterEvent, int> { - CounterBloc() : super(0) { - on<Increment>((event, emit) => emit(state + 1)); - on<Decrement>((event, emit) => emit(state - 1)); - } -} - -// 在 Widget 中使用 -class CounterWidget extends StatelessWidget { - @override - Widget build(BuildContext context) { - return BlocBuilder<CounterBloc, int>( - builder: (context, count) { - return Column( - children: [ - Text('Count: $count'), - ElevatedButton( - onPressed: () => context.read<CounterBloc>().add(Increment()), - child: Text('Increment'), - ), - ], - ); - }, - ); - } -} -``` - -### Cubit 簡化版本 - -```dart -class CounterCubit extends Cubit<int> { - CounterCubit() : super(0); - - void increment() => emit(state + 1); - void decrement() => emit(state - 1); -} -``` - -## 詳細比較 - -### 學習曲線 -- **Riverpod**: 中等,概念相對簡單 -- **Bloc**: 較陡峭,需要理解流和響應式編程 - -### 程式碼簡潔性 -- **Riverpod**: 更簡潔,較少樣板代碼 -- **Bloc**: 較多樣板代碼,但結構清晰 - -### 測試支援 -- **Riverpod**: 優秀,容易模擬和測試 -- **Bloc**: 優秀,內建測試支援 - -### 社群生態 -- **Riverpod**: 快速增長,現代化 -- **Bloc**: 成熟穩定,廣泛使用 - -## 選擇建議 - -### 選擇 Riverpod 當: -- 團隊偏好簡潔的 API -- 需要快速開發 -- 應用狀態相對簡單 - -### 選擇 Bloc 當: -- 團隊熟悉響應式編程 -- 需要嚴格的狀態管理規範 -- 複雜的業務邏輯 - -## 最佳實踐 - -### Riverpod 最佳實踐 -1. 使用 `ref.watch` 監聽變化 -2. 使用 `ref.read` 觸發一次性操作 -3. 合理組合 Provider -4. 使用 `autoDispose` 管理生命週期 - -### Bloc 最佳實踐 -1. 保持事件和狀態的不可變性 -2. 使用 `BlocListener` 處理副作用 -3. 合理分割 Bloc 職責 -4. 使用 `MultiBlocProvider` 組織 Bloc - -## 總結 - -Riverpod 和 Bloc 都是優秀的狀態管理方案。選擇哪一個主要取決於團隊的偏好和專案需求。 diff --git a/frontend/public/content/articles/golang-concurrency-goroutines-channels.md b/frontend/public/content/articles/golang-concurrency-goroutines-channels.md deleted file mode 100644 index a8f22c042..000000000 --- a/frontend/public/content/articles/golang-concurrency-goroutines-channels.md +++ /dev/null @@ -1,113 +0,0 @@ -# Golang 併發編程:Goroutines 與 Channels 深度解析 - -## Go 併發模型概述 - -Go 語言的併發模型基於 CSP (Communicating Sequential Processes) 理論,通過 goroutines 和 channels 實現優雅的併發編程。 - -## Goroutines 基礎 - -```go -package main - -import ( - "fmt" - "time" -) - -func worker(id int) { - fmt.Printf("Worker %d starting\n", id) - time.Sleep(time.Second) - fmt.Printf("Worker %d done\n", id) -} - -func main() { - for i := 1; i <= 5; i++ { - go worker(i) - } - - time.Sleep(time.Second * 2) -} -``` - -## Channels 通信 - -```go -func main() { - ch := make(chan string) - - go func() { - ch <- "Hello from goroutine" - }() - - message := <-ch - fmt.Println(message) -} -``` - -## 緩衝 Channels - -```go -ch := make(chan int, 3) // 緩衝區大小為 3 - -ch <- 1 -ch <- 2 -ch <- 3 -// 不會阻塞,因為緩衝區未滿 -``` - -## Select 語句 - -```go -select { -case msg1 := <-ch1: - fmt.Println("Received from ch1:", msg1) -case msg2 := <-ch2: - fmt.Println("Received from ch2:", msg2) -case <-time.After(1 * time.Second): - fmt.Println("Timeout") -} -``` - -## 併發模式 - -### Worker Pool - -```go -func workerPool(jobs <-chan int, results chan<- int) { - for job := range jobs { - results <- job * 2 - } -} - -func main() { - jobs := make(chan int, 100) - results := make(chan int, 100) - - // 啟動 3 個 worker - for w := 1; w <= 3; w++ { - go workerPool(jobs, results) - } - - // 發送工作 - for j := 1; j <= 5; j++ { - jobs <- j - } - close(jobs) - - // 收集結果 - for a := 1; a <= 5; a++ { - <-results - } -} -``` - -## 最佳實踐 - -1. **避免 goroutine 洩漏** -2. **適當使用緩衝 channels** -3. **使用 context 進行取消操作** -4. **避免共享記憶體,使用通信** - -## 總結 - -Go 的併發模型提供了簡潔而強大的併發編程能力,掌握 goroutines 和 channels 是成為 Go 高手的必經之路。 diff --git a/frontend/public/content/articles/postgresql-performance-optimization.md b/frontend/public/content/articles/postgresql-performance-optimization.md deleted file mode 100644 index 924458279..000000000 --- a/frontend/public/content/articles/postgresql-performance-optimization.md +++ /dev/null @@ -1,213 +0,0 @@ -# PostgreSQL 效能優化:索引策略與查詢調優 - -## 效能優化概述 - -PostgreSQL 效能優化是一個系統性的工程,涉及索引設計、查詢優化、配置調優等多個方面。 - -## 索引策略 - -### 基本索引類型 - -```sql --- B-tree 索引 (預設) -CREATE INDEX idx_users_email ON users(email); - --- 複合索引 -CREATE INDEX idx_orders_user_date ON orders(user_id, created_at); - --- 部分索引 -CREATE INDEX idx_active_users ON users(email) WHERE status = 'active'; - --- 表達式索引 -CREATE INDEX idx_users_lower_email ON users(LOWER(email)); -``` - -### 特殊索引類型 - -```sql --- GIN 索引 (適用於陣列、JSON) -CREATE INDEX idx_tags_gin ON articles USING GIN(tags); - --- GiST 索引 (適用於幾何資料) -CREATE INDEX idx_location_gist ON stores USING GIST(location); - --- Hash 索引 (適用於等值查詢) -CREATE INDEX idx_users_hash ON users USING HASH(user_id); -``` - -## 查詢分析與優化 - -### 使用 EXPLAIN - -```sql --- 查看查詢計劃 -EXPLAIN SELECT * FROM users WHERE email = 'user@example.com'; - --- 查看實際執行統計 -EXPLAIN (ANALYZE, BUFFERS) -SELECT u.name, COUNT(o.id) -FROM users u -LEFT JOIN orders o ON u.id = o.user_id -GROUP BY u.id, u.name; -``` - -### 查詢優化技巧 - -```sql --- 避免 SELECT * -SELECT id, name, email FROM users WHERE status = 'active'; - --- 使用 LIMIT 限制結果 -SELECT * FROM articles ORDER BY created_at DESC LIMIT 10; - --- 適當使用 EXISTS 而非 IN -SELECT * FROM users u -WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id); - --- 使用窗口函數 -SELECT - user_id, - order_date, - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date DESC) as rn -FROM orders; -``` - -## 配置優化 - -### 記憶體配置 - -```sql --- postgresql.conf -shared_buffers = 256MB -- 共享緩衝區 -effective_cache_size = 1GB -- 作業系統快取大小 -work_mem = 4MB -- 排序和雜湊操作記憶體 -maintenance_work_mem = 64MB -- 維護操作記憶體 -``` - -### 連線與寫入優化 - -```sql -max_connections = 100 -- 最大連線數 -checkpoint_timeout = 10min -- 檢查點間隔 -checkpoint_completion_target = 0.9 -wal_buffers = 16MB -- WAL 緩衝區 -``` - -## 監控與維護 - -### 查詢統計 - -```sql --- 啟用查詢統計 -CREATE EXTENSION IF NOT EXISTS pg_stat_statements; - --- 查看最慢的查詢 -SELECT - query, - calls, - total_time, - total_time/calls as avg_time, - rows -FROM pg_stat_statements -ORDER BY total_time DESC -LIMIT 10; -``` - -### 索引使用統計 - -```sql --- 檢查索引使用情況 -SELECT - schemaname, - tablename, - indexname, - idx_tup_read, - idx_tup_fetch -FROM pg_stat_user_indexes -ORDER BY idx_tup_read DESC; - --- 找出未使用的索引 -SELECT - schemaname, - tablename, - indexname -FROM pg_stat_user_indexes -WHERE idx_tup_read = 0 - AND idx_tup_fetch = 0; -``` - -### 表維護 - -```sql --- 分析表統計資訊 -ANALYZE users; - --- 重建索引 -REINDEX INDEX idx_users_email; - --- 清理死元組 -VACUUM ANALYZE users; -``` - -## 進階優化技巧 - -### 分割表 - -```sql --- 建立分割表 -CREATE TABLE orders ( - id SERIAL, - user_id INTEGER, - order_date DATE, - amount DECIMAL -) PARTITION BY RANGE (order_date); - --- 建立子表 -CREATE TABLE orders_2024_q1 PARTITION OF orders -FOR VALUES FROM ('2024-01-01') TO ('2024-04-01'); -``` - -### 物化視圖 - -```sql --- 建立物化視圖 -CREATE MATERIALIZED VIEW user_order_summary AS -SELECT - u.id, - u.name, - COUNT(o.id) as order_count, - SUM(o.amount) as total_amount -FROM users u -LEFT JOIN orders o ON u.id = o.user_id -GROUP BY u.id, u.name; - --- 建立索引 -CREATE INDEX idx_user_order_summary_id ON user_order_summary(id); - --- 重新整理物化視圖 -REFRESH MATERIALIZED VIEW user_order_summary; -``` - -## 效能測試 - -### pgbench 壓力測試 - -```bash -# 初始化測試資料 -pgbench -i -s 10 testdb - -# 執行壓力測試 -pgbench -c 10 -j 2 -t 1000 testdb -``` - -## 最佳實踐 - -1. **定期分析表統計資訊** -2. **監控慢查詢日誌** -3. **合理設計索引策略** -4. **適當使用連線池** -5. **定期維護資料庫** - -## 總結 - -PostgreSQL 效能優化是一個持續的過程。通過合理的索引設計、查詢優化和配置調優,可以顯著提升資料庫效能。 diff --git a/frontend/public/content/articles/rust-ownership-memory-safety.md b/frontend/public/content/articles/rust-ownership-memory-safety.md deleted file mode 100644 index 6fa41a3ca..000000000 --- a/frontend/public/content/articles/rust-ownership-memory-safety.md +++ /dev/null @@ -1,137 +0,0 @@ -# Rust 所有權系統:記憶體安全的革命性方法 - -## 所有權系統概述 - -Rust 的所有權系統是其最獨特的特性,它在編譯時期保證記憶體安全,無需垃圾回收器。 - -## 所有權規則 - -1. Rust 中的每個值都有一個所有者 -2. 在任何時刻,值只能有一個所有者 -3. 當所有者離開作用域時,值會被丟棄 - -## 基本範例 - -```rust -fn main() { - let s1 = String::from("hello"); - let s2 = s1; // s1 的所有權移轉給 s2 - - // println!("{}", s1); // 編譯錯誤!s1 已不再擁有值 - println!("{}", s2); // 正確 -} -``` - -## 借用 (Borrowing) - -```rust -fn main() { - let s1 = String::from("hello"); - - let len = calculate_length(&s1); // 借用 s1 - - println!("The length of '{}' is {}.", s1, len); // s1 仍然有效 -} - -fn calculate_length(s: &String) -> usize { - s.len() -} // s 離開作用域,但因為它不擁有引用的值,所以什麼都不會發生 -``` - -## 可變借用 - -```rust -fn main() { - let mut s = String::from("hello"); - - change(&mut s); - - println!("{}", s); // "hello, world" -} - -fn change(some_string: &mut String) { - some_string.push_str(", world"); -} -``` - -## 借用規則 - -1. 在任何時刻,你可以有**要麼**一個可變引用,**要麼**任意數量的不可變引用 -2. 引用必須總是有效的 - -## 生命週期 - -```rust -fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { - if x.len() > y.len() { - x - } else { - y - } -} - -fn main() { - let string1 = String::from("long string is long"); - - { - let string2 = String::from("xyz"); - let result = longest(string1.as_str(), string2.as_str()); - println!("The longest string is {}", result); - } -} -``` - -## 結構體中的生命週期 - -```rust -struct ImportantExcerpt<'a> { - part: &'a str, -} - -impl<'a> ImportantExcerpt<'a> { - fn level(&self) -> i32 { - 3 - } - - fn announce_and_return_part(&self, announcement: &str) -> &str { - println!("Attention please: {}", announcement); - self.part - } -} -``` - -## 智能指針 - -### Box<T> - -```rust -fn main() { - let b = Box::new(5); - println!("b = {}", b); -} -``` - -### Rc<T> 引用計數 - -```rust -use std::rc::Rc; - -fn main() { - let a = Rc::new(5); - let b = Rc::clone(&a); - let c = Rc::clone(&a); - - println!("Reference count: {}", Rc::strong_count(&a)); // 3 -} -``` - -## 最佳實踐 - -1. **優先使用借用而不是所有權轉移** -2. **儘量使用不可變引用** -3. **避免不必要的克隆** -4. **理解生命週期參數的意義** - -## 總結 - -Rust 的所有權系統雖然學習曲線陡峭,但它提供了無與倫比的記憶體安全保證。掌握這些概念是成為 Rust 專家的關鍵。 diff --git a/frontend/server.ts b/frontend/server.ts deleted file mode 100644 index dec804e55..000000000 --- a/frontend/server.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { - AngularNodeAppEngine, - createNodeRequestHandler, - isMainModule, - writeResponseToNodeResponse, -} from '@angular/ssr/node'; -import express from 'express'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const serverDistFolder = dirname(fileURLToPath(import.meta.url)); -const browserDistFolder = resolve(serverDistFolder, '../browser'); - -const SITE_URL = process.env['SITE_URL'] || 'https://koopa0.dev'; -const SITE_TITLE = 'koopa0.dev'; -const SITE_DESCRIPTION = - 'Software Engineer - Technical articles and personal projects'; - -const BACKEND_URL = process.env['BACKEND_URL'] || 'http://backend:8080'; - -const angularApp = new AngularNodeAppEngine({ - allowedHosts: ['koopa0.dev', 'localhost'], -}); -const app = express(); -app.disable('x-powered-by'); - -// Reject malformed URLs early (e.g. %c0 from scanners) -app.use((req, res, next) => { - try { - decodeURIComponent(req.originalUrl); - next(); - } catch { - res.status(400).end('Bad Request'); - } -}); - -// Security headers -app.use((_req, res, next) => { - res.setHeader( - 'Strict-Transport-Security', - 'max-age=31536000; includeSubDomains', - ); - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); - res.setHeader( - 'Permissions-Policy', - 'camera=(), microphone=(), geolocation=()', - ); - next(); -}); - -// security.txt (RFC 9116) -app.get('/.well-known/security.txt', (_req, res) => { - res - .type('text/plain') - .send( - `Contact: mailto:contact@koopa0.dev\nPreferred-Languages: zh-TW, en\nCanonical: https://koopa0.dev/.well-known/security.txt\nExpires: 2027-01-01T00:00:00.000Z\n`, - ); -}); - -// 靜態頁面路由(用於 sitemap) -const STATIC_ROUTES: Array<{ - path: string; - changefreq: string; - priority: string; -}> = [ - { path: '/', changefreq: 'daily', priority: '1.0' }, - { path: '/articles', changefreq: 'daily', priority: '0.9' }, - { path: '/about', changefreq: 'monthly', priority: '0.7' }, -]; - -// 從後端 API 動態取得已發布內容(用於 sitemap + RSS feed) -interface ContentItem { - slug: string; - title: string; - excerpt: string; - type: string; - topics: Array<{ name: string }> | null; - published_at: string | null; - updated_at: string; -} - -interface ApiListResponse { - data: ContentItem[]; - meta: { total: number; page: number; per_page: number; total_pages: number }; -} - -// Every written content type reads at /articles/:slug (one reading surface); -// the old /essays/:slug and /til/:slug URLs are redirects. -const TYPE_ROUTE_PREFIX: Record<string, string> = { - article: '/articles', - essay: '/articles', - 'build-log': '/articles', - til: '/articles', - digest: '/articles', -}; - -async function fetchPublishedContent(): Promise<ContentItem[]> { - const allItems: ContentItem[] = []; - let page = 1; - let totalPages = 1; - - while (page <= totalPages) { - const res = await fetch( - `${BACKEND_URL}/api/contents?per_page=100&page=${page}`, - ); - if (!res.ok) { - throw new Error(`API returned ${res.status}`); - } - const json = (await res.json()) as ApiListResponse; - allItems.push(...json.data); - totalPages = json.meta.total_pages; - page++; - } - - return allItems; -} - -function escapeXml(text: string): string { - return text - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -// Sitemap XML — 靜態頁面 + 從 API 動態取得的已發布內容 -app.get('/sitemap.xml', async (_req, res) => { - res.set('Cache-Control', 'no-store'); - const now = new Date().toISOString().split('T')[0]; - - const staticUrls = STATIC_ROUTES.map( - (route) => - ` <url> - <loc>${SITE_URL}${route.path}</loc> - <lastmod>${now}</lastmod> - <changefreq>${route.changefreq}</changefreq> - <priority>${route.priority}</priority> - </url>`, - ); - - const contents = await fetchPublishedContent(); - - const priorityMap: Record<string, string> = { - article: '0.7', - essay: '0.6', - til: '0.4', - }; - - const contentUrls = contents - .filter((c) => TYPE_ROUTE_PREFIX[c.type]) - .map((c) => { - const prefix = TYPE_ROUTE_PREFIX[c.type]; - const lastmod = (c.published_at ?? c.updated_at).split('T')[0]; - const priority = priorityMap[c.type] ?? '0.5'; - return ` <url> - <loc>${SITE_URL}${prefix}/${c.slug}</loc> - <lastmod>${lastmod}</lastmod> - <changefreq>monthly</changefreq> - <priority>${priority}</priority> - </url>`; - }); - - const xml = `<?xml version="1.0" encoding="UTF-8"?> -<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> -${[...staticUrls, ...contentUrls].join('\n')} -</urlset>`; - - res.set('Content-Type', 'application/xml'); - res.send(xml); -}); - -// RSS Feed — 從 API 動態取得所有已發布內容 -app.get('/feed.xml', async (_req, res) => { - res.set('Cache-Control', 'no-store'); - const contents = await fetchPublishedContent(); - - const feedItems = contents - .filter((c) => TYPE_ROUTE_PREFIX[c.type]) - .sort( - (a, b) => - new Date(b.published_at ?? b.updated_at).getTime() - - new Date(a.published_at ?? a.updated_at).getTime(), - ); - - const latestDate = - feedItems.length > 0 - ? new Date( - feedItems[0].published_at ?? feedItems[0].updated_at, - ).toUTCString() - : new Date().toUTCString(); - - const items = feedItems - .map((c) => { - const prefix = TYPE_ROUTE_PREFIX[c.type]; - const link = `${SITE_URL}${prefix}/${c.slug}`; - const pubDate = new Date(c.published_at ?? c.updated_at).toUTCString(); - const categories = (c.topics ?? []) - .map((topic) => ` <category>${escapeXml(topic.name)}</category>`) - .join('\n'); - return ` <item> - <title>${escapeXml(c.title)} - ${link} - ${link} - ${escapeXml(c.excerpt)} - ${pubDate} -${categories} - `; - }) - .join('\n'); - - const xml = ` - - - ${escapeXml(SITE_TITLE)} - ${escapeXml(SITE_DESCRIPTION)} - ${SITE_URL} - - zh-TW - ${latestDate} - Angular SSR -${items} - -`; - - res.set('Content-Type', 'application/rss+xml'); - res.send(xml); -}); - -// 健康檢查端點(部署用) -app.get('/api/health', (_req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); - -// API Proxy — 轉發 /api/* 到後端,後端零暴露。瀏覽器與 SSR 共用同一條 /api -// 路徑,HTTP transfer cache 的 key 在兩端才會一致(僅 origin 不同,由 -// HTTP_TRANSFER_CACHE_ORIGIN_MAP 橋接)。/api/health 在此之前註冊,優先匹配。 -const BFF_MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB - -app.use('/api', (req, res) => { - const targetUrl = `${BACKEND_URL}${req.originalUrl}`; - const headers: Record = { - 'content-type': req.headers['content-type'] || 'application/json', - }; - - // 轉發真實 client IP,讓後端 rate limiter 按用戶限流 - const clientIp = - (req.headers['cf-connecting-ip'] as string) || - (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || - req.socket.remoteAddress || - ''; - if (clientIp) { - headers['x-forwarded-for'] = clientIp; - } - - const forwardHeaders = [ - 'authorization', - 'cookie', - 'x-hub-signature-256', - 'x-github-event', - 'x-github-delivery', - ]; - for (const h of forwardHeaders) { - if (req.headers[h]) { - headers[h] = req.headers[h] as string; - } - } - - // 限制 body 大小,防止記憶體 DoS - let receivedBytes = 0; - const bodyChunks: Buffer[] = []; - - req.on('data', (chunk: Buffer) => { - receivedBytes += chunk.length; - if (receivedBytes > BFF_MAX_BODY_BYTES) { - req.destroy(); - res.status(413).json({ error: 'Payload too large' }); - return; - } - bodyChunks.push(chunk); - }); - - req.on('end', () => { - if (receivedBytes > BFF_MAX_BODY_BYTES) { - return; - } - const body = bodyChunks.length > 0 ? Buffer.concat(bodyChunks) : undefined; - fetch(targetUrl, { - method: req.method, - headers, - body, - }) - .then(async (upstream) => { - res.status(upstream.status); - upstream.headers.forEach((value, key) => { - if ( - !['transfer-encoding', 'content-encoding'].includes( - key.toLowerCase(), - ) - ) { - res.setHeader(key, value); - } - }); - const data = await upstream.arrayBuffer(); - res.send(Buffer.from(data)); - }) - .catch((err) => { - console.error('BFF proxy error:', err); - res.status(502).json({ error: 'Backend unavailable' }); - }); - }); -}); - -// 靜態檔案 -app.use( - express.static(browserDistFolder, { - maxAge: '1y', - index: false, - redirect: false, - }), -); - -// Angular SSR -app.get('/{*path}', (req, res, next) => { - angularApp - .handle(req) - .then((response) => { - if (response) { - writeResponseToNodeResponse(response, res); - } else { - next(); - } - }) - .catch((err) => { - console.error(`SSR error on ${req.method} ${req.originalUrl}:`, err); - next(err); - }); -}); - -if (isMainModule(import.meta.url)) { - const port = process.env['PORT'] || 4000; - app.listen(port, () => { - console.log(`Node Express server listening on http://localhost:${port}`); - }); -} - -// The Angular build tooling (dev-server SSR middleware and prerender server) -// loads this entry and destructures a named `reqHandler` export -// (@angular/build .../ssr-middleware.js). A default export leaves reqHandler -// undefined and the tooling warns + falls back, so export it by name. -export const reqHandler = createNodeRequestHandler(app); diff --git a/frontend/src/app/admin/knowledge/content/editor/preview-overlay.component.ts b/frontend/src/app/admin/knowledge/content/editor/preview-overlay.component.ts index b0cbbefe5..811f17b4c 100644 --- a/frontend/src/app/admin/knowledge/content/editor/preview-overlay.component.ts +++ b/frontend/src/app/admin/knowledge/content/editor/preview-overlay.component.ts @@ -7,6 +7,7 @@ import { } from '@angular/core'; import { A11yModule } from '@angular/cdk/a11y'; import type { ApiContent } from '../../../../core/models/api.model'; +import type { Article } from '../../../../core/services/publication.service'; import { contentTypeRoute } from '../../../../core/models/content-type.config'; import { ArticleDetailComponent } from '../../../../pages/article-detail/article-detail'; @@ -77,7 +78,7 @@ import { ArticleDetailComponent } from '../../../../pages/article-detail/article > @@ -93,6 +94,29 @@ export class ContentPreviewOverlayComponent { /** Persisted API snapshot; never constructed from the editor form. */ readonly content = input.required(); + /** + * The reading component is a public page and takes the published wire shape. + * Adapting here keeps that boundary one-way: the admin borrows the component, + * the component does not learn about editorial state. + */ + protected readonly preview = computed
(() => { + const c = this.content(); + return { + slug: c.slug, + title: c.title, + body: c.body, + excerpt: c.excerpt, + topics: c.topics.map((t) => t.slug), + cover_image: c.cover_image ?? undefined, + published_at: c.published_at ?? new Date().toISOString(), + reading_time_min: c.reading_time_min, + source: { + vault_path: c.source?.vault_path ?? '', + git_blob_sha: c.source?.git_blob_sha ?? '', + }, + }; + }); + readonly closed = output(); protected readonly isLive = computed( diff --git a/frontend/src/app/app.config.server.ts b/frontend/src/app/app.config.server.ts index fbcec4613..be8f6fb33 100644 --- a/frontend/src/app/app.config.server.ts +++ b/frontend/src/app/app.config.server.ts @@ -19,6 +19,12 @@ const serverConfig: ApplicationConfig = { useValue: { [new URL(environment.ssrApiUrl).origin]: new URL(environment.apiUrl) .origin, + // Same reasoning for the generated publication data, which prerendering + // reads from a build-time file server and the browser reads from the + // deployed site. + [new URL(environment.buildContentUrl).origin]: new URL( + environment.contentUrl, + ).origin, }, }, ], diff --git a/frontend/src/app/app.routes.server.ts b/frontend/src/app/app.routes.server.ts index 3fc28cc36..229e9da82 100644 --- a/frontend/src/app/app.routes.server.ts +++ b/frontend/src/app/app.routes.server.ts @@ -1,25 +1,56 @@ import { RenderMode, ServerRoute } from '@angular/ssr'; +import { environment } from '../environments/environment'; +/** + * Reads the generated index — the same file the application reads at runtime, + * so a page can never be built from data the deployed site will not have. + */ +async function index(): Promise<{ + articles?: { slug: string }[]; + topics?: { slug: string }[]; +}> { + const res = await fetch(`${environment.buildContentUrl}/content/index.json`); + return res.json(); +} + +async function articleSlugs(): Promise<{ slug: string }[]> { + const { articles = [] } = await index(); + return articles.map(({ slug }) => ({ slug })); +} + +async function topicSlugs(): Promise<{ slug: string }[]> { + const { topics = [] } = await index(); + return topics.map(({ slug }) => ({ slug })); +} + +/** + * Every public route is prerendered. The site is a build artifact with no + * server behind it, so a route that cannot be rendered at build time cannot be + * served at all — which is the property that makes the deployment a directory + * of files. + */ export const serverRoutes: ServerRoute[] = [ { path: '', - renderMode: RenderMode.Server, + renderMode: RenderMode.Prerender, }, { path: 'articles', - renderMode: RenderMode.Server, + renderMode: RenderMode.Prerender, }, { path: 'articles/:slug', - renderMode: RenderMode.Server, + renderMode: RenderMode.Prerender, + getPrerenderParams: articleSlugs, }, { path: 'topics', - renderMode: RenderMode.Server, + renderMode: RenderMode.Prerender, }, { path: 'topics/:slug', - renderMode: RenderMode.Server, + renderMode: RenderMode.Prerender, + getPrerenderParams: topicSlugs, }, { path: 'about', @@ -40,28 +71,14 @@ export const serverRoutes: ServerRoute[] = [ path: 'terms', renderMode: RenderMode.Prerender, }, - { - path: 'login', - renderMode: RenderMode.Client, - }, { path: 'error', renderMode: RenderMode.Client, }, { - path: 'admin/oauth-callback', - renderMode: RenderMode.Client, - }, - { - path: 'admin', - renderMode: RenderMode.Client, - }, - { - path: 'admin/**', - renderMode: RenderMode.Client, - }, - { + // The static host serves its own 404 for an unknown path; this keeps the + // app's own not-found page working for a bad in-app link. path: '**', - renderMode: RenderMode.Server, + renderMode: RenderMode.Client, }, ]; diff --git a/frontend/src/app/core/services/publication.service.ts b/frontend/src/app/core/services/publication.service.ts new file mode 100644 index 000000000..83297ff33 --- /dev/null +++ b/frontend/src/app/core/services/publication.service.ts @@ -0,0 +1,95 @@ +import { Injectable, inject, PLATFORM_ID } from '@angular/core'; +import { isPlatformServer } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { Observable, map, shareReplay, switchMap, throwError } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +/** The wire contract shared with `internal/publication`. */ +export interface Article { + slug: string; + title: string; + body: string; + excerpt: string; + topics: string[]; + cover_image?: string; + published_at: string; + reading_time_min: number; + source: { + vault_path: string; + git_blob_sha: string; + }; +} + +/** An article without its body, as listed in the index. */ +export type ArticleSummary = Omit; + +/** A tag and its article count, derived at build time. */ +export interface Topic { + slug: string; + count: number; +} + +/** The generated index: every article, newest first, and the topics they use. */ +export interface Index { + articles: ArticleSummary[]; + topics: Topic[]; +} + +/** + * Reads the corpus from the static files `cmd/publish` generates. There is no + * API: the index is one small file, fetched once and filtered in memory. + */ +@Injectable({ providedIn: 'root' }) +export class PublicationService { + private readonly http = inject(HttpClient); + private readonly platformId = inject(PLATFORM_ID); + + /** Fetched once; the transfer cache carries it across hydration. */ + private readonly index$ = this.http + .get(this.url('/content/index.json')) + .pipe(shareReplay({ bufferSize: 1, refCount: false })); + + /** Every published article, newest first. */ + articles(): Observable { + return this.index$.pipe(map((index) => index.articles)); + } + + /** Every topic in use, in first-published order. */ + topics(): Observable { + return this.index$.pipe(map((index) => index.topics)); + } + + /** Articles carrying a topic, newest first. */ + articlesByTopic(topic: string): Observable { + const wanted = topic.toLowerCase(); + return this.articles().pipe( + map((articles) => articles.filter((a) => a.topics.includes(wanted))), + ); + } + + /** + * Checks the index first, so an unknown slug fails the same way whatever a + * host serves for a missing file — an HTML 404 page would otherwise surface + * as a parse error. + */ + article(slug: string): Observable
{ + return this.index$.pipe( + switchMap((index) => { + if (!index.articles.some((a) => a.slug === slug)) { + return throwError(() => new Error(`no published article "${slug}"`)); + } + return this.http.get
( + this.url(`/content/${encodeURIComponent(slug)}.json`), + ); + }), + ); + } + + /** HTTP_TRANSFER_CACHE_ORIGIN_MAP maps these two origins onto each other. */ + private url(path: string): string { + const base = isPlatformServer(this.platformId) + ? environment.buildContentUrl + : environment.contentUrl; + return `${base}${path}`; + } +} diff --git a/frontend/src/app/pages/article-detail/article-detail.html b/frontend/src/app/pages/article-detail/article-detail.html index 326f8744c..5d5d03d8f 100644 --- a/frontend/src/app/pages/article-detail/article-detail.html +++ b/frontend/src/app/pages/article-detail/article-detail.html @@ -25,14 +25,7 @@

{{ content.title }}

}

- - {{ content.type }} - @if (content.published_at) { - · {{ content.published_at | date: 'MMM d, yyyy' : 'UTC' }} - } + {{ content.published_at | date: 'MMM d, yyyy' : 'UTC' }} · {{ content.reading_time_min }} min

diff --git a/frontend/src/app/pages/article-detail/article-detail.spec.ts b/frontend/src/app/pages/article-detail/article-detail.spec.ts index 85b27c395..c056d0da3 100644 --- a/frontend/src/app/pages/article-detail/article-detail.spec.ts +++ b/frontend/src/app/pages/article-detail/article-detail.spec.ts @@ -8,27 +8,19 @@ import { provideRouter } from '@angular/router'; import { PLATFORM_ID } from '@angular/core'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { ArticleDetailComponent } from './article-detail'; -import type { ApiContent, ContentType } from '../../core/models'; +import type { Article } from '../../core/services/publication.service'; import { SeoService } from '../../core/services/seo/seo.service'; -function buildMockContent(overrides: Partial = {}): ApiContent { +function buildMockArticle(overrides: Partial
= {}): Article { return { - id: 'test-1', slug: 'test-article', title: 'Test Article', excerpt: 'A test excerpt', body: '## Section one\n\nBody text.', - type: 'article', - status: 'published', topics: [], - cover_image: null, - series_id: null, - series_order: null, - is_public: true, reading_time_min: 5, published_at: '2026-01-15T00:00:00Z', - created_at: '2026-01-15T00:00:00Z', - updated_at: '2026-01-15T00:00:00Z', + source: { vault_path: 'Writing/test-article.md', git_blob_sha: 'abc123' }, ...overrides, }; } @@ -62,59 +54,42 @@ describe('ArticleDetailComponent', () => { } it('should create', async () => { - fixture.componentRef.setInput('article', buildMockContent()); + fixture.componentRef.setInput('article', buildMockArticle()); fixture.detectChanges(); await settle(); expect(component).toBeTruthy(); }); it('should render the back link, title, meta line, and prose in full mode', async () => { - fixture.componentRef.setInput('article', buildMockContent()); + fixture.componentRef.setInput('article', buildMockArticle()); fixture.detectChanges(); await settle(); const el = fixture.nativeElement as HTMLElement; // The quiet mono back link points at the archive. const crumb = el.querySelector('a.ed-crumb'); expect(crumb?.getAttribute('href')).toBe('/articles'); - // One inline mono meta line carries type, date, and reading time. - expect(el.querySelector('.ed-metaline')).toBeTruthy(); - // The reading column rendered the title, prose body, and reading time. + // One inline mono meta line carries the date and the reading time. + expect(el.querySelector('.ed-metaline')?.textContent).toContain( + 'Jan 15, 2026', + ); + expect(el.querySelector('.ed-metaline')?.textContent).toContain('5 min'); + // The reading column rendered the title and the prose body. expect(el.querySelector('.ed-prose')).toBeTruthy(); expect(el.textContent).toContain('Test Article'); - expect(el.textContent).toContain('5 min'); }); - it('should not fetch or render the retired read-next surface', async () => { - fixture.componentRef.setInput('article', buildMockContent()); + it('should render the resolved article without issuing any request', async () => { + fixture.componentRef.setInput('article', buildMockArticle()); fixture.detectChanges(); await settle(); - httpTesting.expectNone((r) => r.url.includes('/api/contents/related/')); - expect( - (fixture.nativeElement as HTMLElement).querySelector( - 'app-related-articles', - ), - ).toBeNull(); + // The page renders what the resolver already fetched; a read of its own + // would refetch on every hydration. + expect(httpTesting.match(() => true)).toHaveLength(0); }); - it.each(['article', 'essay', 'build-log', 'til', 'digest'])( - 'should render %s content on the same reading surface', - async (type) => { - fixture.componentRef.setInput( - 'article', - buildMockContent({ type, slug: `some-${type}`, title: `A ${type}` }), - ); - fixture.detectChanges(); - await settle(); - const el = fixture.nativeElement as HTMLElement; - expect(el.textContent).toContain(`A ${type}`); - // The mono meta line shows the raw content type (e.g. "build-log"). - expect(el.querySelector('.ed-metaline')?.textContent).toContain(type); - }, - ); - it('should hide the back link in preview mode', async () => { - fixture.componentRef.setInput('article', buildMockContent()); + fixture.componentRef.setInput('article', buildMockArticle()); fixture.componentRef.setInput('preview', true); fixture.detectChanges(); await settle(); @@ -129,7 +104,7 @@ describe('ArticleDetailComponent', () => { it('should not mutate document metadata when embedded as a preview', async () => { const updateMeta = vi.spyOn(TestBed.inject(SeoService), 'updateMeta'); - fixture.componentRef.setInput('article', buildMockContent()); + fixture.componentRef.setInput('article', buildMockArticle()); fixture.componentRef.setInput('preview', true); fixture.detectChanges(); await settle(); @@ -139,10 +114,14 @@ describe('ArticleDetailComponent', () => { it('should update document metadata on the public article page', async () => { const updateMeta = vi.spyOn(TestBed.inject(SeoService), 'updateMeta'); - fixture.componentRef.setInput('article', buildMockContent()); + fixture.componentRef.setInput('article', buildMockArticle()); fixture.detectChanges(); await settle(); expect(updateMeta).toHaveBeenCalledOnce(); + const meta = updateMeta.mock.calls[0][0]; + expect(meta.canonicalUrl).toContain('/articles/test-article'); + // The JSON-LD publication date is the article's own, not a fallback. + expect(meta.jsonLd?.['datePublished']).toBe('2026-01-15T00:00:00Z'); }); }); diff --git a/frontend/src/app/pages/article-detail/article-detail.ts b/frontend/src/app/pages/article-detail/article-detail.ts index dd63f102e..32546119e 100644 --- a/frontend/src/app/pages/article-detail/article-detail.ts +++ b/frontend/src/app/pages/article-detail/article-detail.ts @@ -14,19 +14,18 @@ import { RouterLink } from '@angular/router'; import { environment } from '../../../environments/environment'; import { MarkdownService } from '../../core/services/markdown.service'; import { ThemeService } from '../../core/services/theme.service'; -import type { ApiContent } from '../../core/models'; +import type { Article } from '../../core/services/publication.service'; import { SeoService } from '../../core/services/seo/seo.service'; import { buildBlogPostingSchema } from '../../core/services/seo/json-ld.util'; /** - * The reading surface — renders every written content type (article / - * essay / build-log / til / digest). The article is resolved by - * {@link articleResolver} before the route activates (so the page-level view - * transition lands on the finished page, never a spinner) and arrives via the - * `article` input. Has two homes: /articles/:slug (a centered reading column: - * back link, title, dek, one mono meta line, the mended seam, and the prose - * body) and the admin's inline publication preview (a chrome-less column that - * does not own document metadata). + * The reading surface. The article is resolved by {@link articleResolver} + * before the route activates (so the page-level view transition lands on the + * finished page, never a spinner) and arrives via the `article` input. Has two + * homes: /articles/:slug (a centered reading column: back link, title, dek, + * one mono meta line, the mended seam, and the prose body) and the admin's + * inline publication preview (a chrome-less column that does not own document + * metadata). */ @Component({ selector: 'app-article-detail', @@ -37,7 +36,7 @@ import { buildBlogPostingSchema } from '../../core/services/seo/json-ld.util'; export class ArticleDetailComponent { /** The resolved article — bound from the route's resolve key via * withComponentInputBinding, so it is always present at first render. */ - readonly article = input.required(); + readonly article = input.required
(); /** Embedded admin preview flag: renders the bare reading column. */ readonly preview = input(false); @@ -97,14 +96,14 @@ export class ArticleDetailComponent { }); } - private updateSeo(article: ApiContent): void { + private updateSeo(article: Article): void { const articleUrl = `${environment.siteUrl}/articles/${article.slug}`; this.seoService.updateMeta({ title: article.title, description: article.excerpt, ogTitle: article.title, ogDescription: article.excerpt, - ogImage: article.cover_image ?? undefined, + ogImage: article.cover_image, ogUrl: articleUrl, ogType: 'article', twitterCard: 'summary_large_image', @@ -113,9 +112,8 @@ export class ArticleDetailComponent { title: article.title, description: article.excerpt, url: articleUrl, - publishedAt: article.published_at ?? article.created_at, - updatedAt: article.updated_at, - coverImage: article.cover_image ?? undefined, + publishedAt: article.published_at, + coverImage: article.cover_image, }), }); } diff --git a/frontend/src/app/pages/article-detail/article-resolver.spec.ts b/frontend/src/app/pages/article-detail/article-resolver.spec.ts index 99372ceae..b9297f9e1 100644 --- a/frontend/src/app/pages/article-detail/article-resolver.spec.ts +++ b/frontend/src/app/pages/article-detail/article-resolver.spec.ts @@ -13,30 +13,44 @@ import { } from '@angular/router'; import { firstValueFrom, type Observable } from 'rxjs'; import { articleResolver } from './article-resolver'; -import type { ApiContent } from '../../core/models'; +import type { + Article, + ArticleSummary, + Index, +} from '../../core/services/publication.service'; -function buildMockContent(overrides: Partial = {}): ApiContent { +function buildMockArticle(overrides: Partial
= {}): Article { return { - id: 'test-1', slug: 'a-piece', title: 'A Piece', excerpt: 'An excerpt', body: 'Body text.', - type: 'article', - status: 'published', topics: [], - cover_image: null, - series_id: null, - series_order: null, - is_public: true, reading_time_min: 5, published_at: '2026-01-15T00:00:00Z', - created_at: '2026-01-15T00:00:00Z', - updated_at: '2026-01-15T00:00:00Z', + source: { vault_path: 'Writing/a-piece.md', git_blob_sha: 'abc123' }, ...overrides, }; } +/** The index lists every article without its body or provenance. */ +function buildMockIndex(articles: Article[]): Index { + return { + articles: articles.map( + (article): ArticleSummary => ({ + slug: article.slug, + title: article.title, + excerpt: article.excerpt, + topics: article.topics, + cover_image: article.cover_image, + published_at: article.published_at, + reading_time_min: article.reading_time_min, + }), + ), + topics: [], + }; +} + describe('articleResolver', () => { let httpTesting: HttpTestingController; @@ -53,32 +67,55 @@ describe('articleResolver', () => { afterEach(() => httpTesting.verify()); - function run(slug: string): Observable { + function run(slug: string): Observable
{ const route = { paramMap: convertToParamMap({ slug }), } as ActivatedRouteSnapshot; const state = {} as RouterStateSnapshot; return TestBed.runInInjectionContext( () => - articleResolver(route, state) as Observable< - ApiContent | RedirectCommand - >, + articleResolver(route, state) as Observable
, ); } + function flushIndex(articles: Article[]): void { + httpTesting + .expectOne((r) => r.url.endsWith('/content/index.json')) + .flush(buildMockIndex(articles)); + } + it('should resolve the article on success', async () => { + const article = buildMockArticle(); const result = firstValueFrom(run('a-piece')); + + flushIndex([article]); httpTesting - .expectOne((r) => r.url.includes('/api/contents/a-piece')) - .flush({ data: buildMockContent() }); + .expectOne((r) => r.url.endsWith('/content/a-piece.json')) + .flush(article); - expect(await result).toEqual(buildMockContent()); + expect(await result).toEqual(article); }); - it('should redirect a 404 to the not-found page', async () => { + it('should redirect an unpublished slug to the not-found page', async () => { const result = firstValueFrom(run('missing')); + + flushIndex([buildMockArticle()]); + // The index is the whole truth about what exists, so no file is requested. + httpTesting.expectNone((r) => r.url.endsWith('/content/missing.json')); + + const resolved = await result; + expect(resolved).toBeInstanceOf(RedirectCommand); + expect((resolved as RedirectCommand).redirectTo.toString()).toBe( + '/not-found', + ); + }); + + it('should redirect a missing article file to the not-found page', async () => { + const result = firstValueFrom(run('a-piece')); + + flushIndex([buildMockArticle()]); httpTesting - .expectOne((r) => r.url.includes('/api/contents/missing')) + .expectOne((r) => r.url.endsWith('/content/a-piece.json')) .flush('nope', { status: 404, statusText: 'Not Found' }); const resolved = await result; @@ -88,10 +125,11 @@ describe('articleResolver', () => { ); }); - it('should redirect a 500 to the error page', async () => { - const result = firstValueFrom(run('broken')); + it('should redirect an unreadable index to the error page', async () => { + const result = firstValueFrom(run('a-piece')); + httpTesting - .expectOne((r) => r.url.includes('/api/contents/broken')) + .expectOne((r) => r.url.endsWith('/content/index.json')) .flush('boom', { status: 500, statusText: 'Internal Server Error' }); const resolved = await result; diff --git a/frontend/src/app/pages/article-detail/article-resolver.ts b/frontend/src/app/pages/article-detail/article-resolver.ts index 7bec0b249..185d072ac 100644 --- a/frontend/src/app/pages/article-detail/article-resolver.ts +++ b/frontend/src/app/pages/article-detail/article-resolver.ts @@ -2,8 +2,10 @@ import { inject } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; import { ResolveFn, RedirectCommand, Router } from '@angular/router'; import { catchError, of } from 'rxjs'; -import { ArticleService } from '../../core/services/article.service'; -import type { ApiContent } from '../../core/models'; +import { + PublicationService, + type Article, +} from '../../core/services/publication.service'; /** * Resolves the article before the route activates so the page-level view @@ -11,17 +13,20 @@ import type { ApiContent } from '../../core/models'; * loading spinner. Running on the server (RenderMode.Server) populates the * transfer cache, so the client never refetches on hydration. * - * A missing or unpublished slug (404) routes to the not-found page; any other + * A slug the corpus does not publish routes to the not-found page; any other * failure (network / 5xx) routes to the error page. */ -export const articleResolver: ResolveFn = (route) => { +export const articleResolver: ResolveFn
= (route) => { const slug = route.paramMap.get('slug') ?? ''; - const articles = inject(ArticleService); + const publication = inject(PublicationService); const router = inject(Router); - return articles.getArticleBySlug(slug).pipe( + return publication.article(slug).pipe( catchError((err: unknown) => { - const notFound = err instanceof HttpErrorResponse && err.status === 404; + // An unknown slug is rejected against the index before anything is + // fetched, so a non-HTTP failure means the article does not exist. + const notFound = + err instanceof HttpErrorResponse ? err.status === 404 : true; return of( new RedirectCommand( router.parseUrl(notFound ? '/not-found' : '/error'), diff --git a/frontend/src/app/pages/articles/articles.html b/frontend/src/app/pages/articles/articles.html index 484af446e..11baa937e 100644 --- a/frontend/src/app/pages/articles/articles.html +++ b/frontend/src/app/pages/articles/articles.html @@ -4,31 +4,6 @@

Everything I've written down.

-
- - @for (t of contentTypes; track t) { - - } -
- @defer (on immediate; hydrate on idle) { @if (isLoading()) {
@@ -38,7 +13,7 @@

Everything I've written down.

- {{ page() }} / {{ totalPages() }} - - - } } @else {

Nothing here yet.

} diff --git a/frontend/src/app/pages/articles/articles.spec.ts b/frontend/src/app/pages/articles/articles.spec.ts index 50f312f64..d94f593b5 100644 --- a/frontend/src/app/pages/articles/articles.spec.ts +++ b/frontend/src/app/pages/articles/articles.spec.ts @@ -7,43 +7,27 @@ import { provideHttpClient, withXhr } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController, + type TestRequest, } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { Title } from '@angular/platform-browser'; import { ArticlesComponent } from './articles'; -import type { ApiContent, ApiPaginationMeta } from '../../core/models'; - -function buildMockContent(overrides: Partial = {}): ApiContent { +import type { + ArticleSummary, + Topic, +} from '../../core/services/publication.service'; + +function buildMockArticle( + overrides: Partial = {}, +): ArticleSummary { return { - id: 'test-1', slug: 'test-article', title: 'Test Article', excerpt: 'A test excerpt', - body: '', - type: 'article', - status: 'published', topics: [], - cover_image: null, - series_id: null, - series_order: null, - is_public: true, reading_time_min: 5, published_at: '2026-01-15T00:00:00Z', - created_at: '2026-01-15T00:00:00Z', - updated_at: '2026-01-15T00:00:00Z', - ...overrides, - }; -} - -function buildMockMeta( - overrides: Partial = {}, -): ApiPaginationMeta { - return { - total: 1, - page: 1, - per_page: 50, - total_pages: 1, ...overrides, }; } @@ -88,67 +72,65 @@ describe('ArticlesComponent', () => { fixture.detectChanges(); } - function flushContents( - contents: ApiContent[], - meta: ApiPaginationMeta = buildMockMeta({ total: contents.length }), - ): void { - const req = httpTesting.expectOne( - (r) => r.url.includes('/api/contents') && r.method === 'GET', + function expectIndexRequest(): TestRequest { + return httpTesting.expectOne( + (r) => r.url.includes('/content/index.json') && r.method === 'GET', ); - req.flush({ data: contents, meta }); + } + + function flushIndex(articles: ArticleSummary[], topics: Topic[] = []): void { + expectIndexRequest().flush({ articles, topics }); } it('should create', async () => { await settle(); - flushContents([]); + flushIndex([]); expect(component).toBeTruthy(); }); it('should set the plural archive SEO title', async () => { await settle(); - flushContents([]); + flushIndex([]); expect(TestBed.inject(Title).getTitle()).toContain('Articles |'); }); it('should render the archive page title', async () => { await settle(); - flushContents([]); + flushIndex([]); await settle(); const el = fixture.nativeElement as HTMLElement; expect(el.textContent).toContain("Everything I've written down."); }); - it('should render a spine entry for every written content type when loaded', async () => { + it('should render a spine entry for every article in the index', async () => { await settle(); - flushContents([ - buildMockContent({ id: '1', title: 'An Article', type: 'article' }), - buildMockContent({ id: '2', title: 'An Essay', type: 'essay' }), - buildMockContent({ id: '3', title: 'A Build Log', type: 'build-log' }), - buildMockContent({ id: '4', title: 'A TIL', type: 'til' }), - buildMockContent({ id: '5', title: 'A Digest', type: 'digest' }), + flushIndex([ + buildMockArticle({ slug: 'first', title: 'An Article' }), + buildMockArticle({ slug: 'second', title: 'Another Article' }), + buildMockArticle({ slug: 'third', title: 'A Third Article' }), ]); await settle(); await renderList(); const el = fixture.nativeElement as HTMLElement; const rows = el.querySelectorAll('.ed-entry'); - expect(rows.length).toBe(5); - expect(el.textContent).toContain('An Essay'); - expect(el.textContent).toContain('A Build Log'); + expect(rows.length).toBe(3); + expect(el.textContent).toContain('An Article'); + expect(el.textContent).toContain('A Third Article'); }); it('should group rows by published year, newest year first', async () => { await settle(); - flushContents([ - buildMockContent({ - id: '1', + flushIndex([ + buildMockArticle({ + slug: 'newer', title: 'newer', published_at: '2026-06-01T00:00:00Z', }), - buildMockContent({ - id: '2', + buildMockArticle({ + slug: 'older', title: 'older', published_at: '2025-03-01T00:00:00Z', }), @@ -168,41 +150,34 @@ describe('ArticlesComponent', () => { it('should link every row to the single reading surface at /articles/:slug', async () => { await settle(); - flushContents([buildMockContent({ id: '1', slug: 'my-til', type: 'til' })]); + flushIndex([buildMockArticle({ slug: 'my-piece' })]); await settle(); await renderList(); const row = (fixture.nativeElement as HTMLElement).querySelector( '.ed-entry', ); - expect(row?.getAttribute('href')).toBe('/articles/my-til'); + expect(row?.getAttribute('href')).toBe('/articles/my-piece'); }); - it('should pass the type query param to the contents request', async () => { - fixture.componentRef.setInput('type', 'til'); - await settle(); - - const req = httpTesting.expectOne( - (r) => r.url.includes('/api/contents') && r.method === 'GET', + it('should render the whole corpus from one request without paginating', async () => { + const many = Array.from({ length: 60 }, (_, i) => + buildMockArticle({ slug: `piece-${i}`, title: `Piece ${i}` }), ); - expect(req.request.params.get('type')).toBe('til'); - req.flush({ data: [], meta: buildMockMeta({ total: 0 }) }); - }); - it('should ignore an unknown type query param', async () => { - fixture.componentRef.setInput('type', 'bogus'); await settle(); + flushIndex(many); + await settle(); + await renderList(); - const req = httpTesting.expectOne( - (r) => r.url.includes('/api/contents') && r.method === 'GET', - ); - expect(req.request.params.has('type')).toBe(false); - req.flush({ data: [], meta: buildMockMeta({ total: 0 }) }); + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelectorAll('.ed-entry').length).toBe(60); + expect(el.querySelector('nav[aria-label="Pagination"]')).toBeNull(); }); - it('should show the empty state when no contents are returned', async () => { + it('should show the empty state when the index holds no articles', async () => { await settle(); - flushContents([]); + flushIndex([]); await settle(); await renderList(); @@ -216,10 +191,7 @@ describe('ArticlesComponent', () => { it('should show an error state when the request fails', async () => { await settle(); - const req = httpTesting.expectOne( - (r) => r.url.includes('/api/contents') && r.method === 'GET', - ); - req.flush('Server error', { + expectIndexRequest().flush('Server error', { status: 500, statusText: 'Internal Server Error', }); @@ -233,29 +205,4 @@ describe('ArticlesComponent', () => { expect(el.textContent).toContain("Couldn't load the index"); expect(el.textContent).not.toContain('Nothing here yet'); }); - - it('should hide pagination when there is only one page', async () => { - await settle(); - flushContents([buildMockContent()], buildMockMeta({ total_pages: 1 })); - await settle(); - await renderList(); - - const el = fixture.nativeElement as HTMLElement; - expect(el.querySelector('nav[aria-label="Pagination"]')).toBeNull(); - }); - - it('should show pagination when there are multiple pages', async () => { - await settle(); - flushContents( - [buildMockContent()], - buildMockMeta({ total: 120, total_pages: 3 }), - ); - await settle(); - await renderList(); - - const el = fixture.nativeElement as HTMLElement; - const pager = el.querySelector('nav[aria-label="Pagination"]'); - expect(pager).not.toBeNull(); - expect(pager?.textContent).toContain('1 / 3'); - }); }); diff --git a/frontend/src/app/pages/articles/articles.ts b/frontend/src/app/pages/articles/articles.ts index e8a2d58d6..bdf507b19 100644 --- a/frontend/src/app/pages/articles/articles.ts +++ b/frontend/src/app/pages/articles/articles.ts @@ -4,46 +4,26 @@ import { OnInit, computed, inject, - input, - linkedSignal, } from '@angular/core'; import { DatePipe } from '@angular/common'; -import { Router, RouterLink } from '@angular/router'; +import { RouterLink } from '@angular/router'; import { rxResource } from '@angular/core/rxjs-interop'; import { environment } from '../../../environments/environment'; -import { ContentService } from '../../core/services/content.service'; +import { + PublicationService, + type ArticleSummary, +} from '../../core/services/publication.service'; import { SeoService } from '../../core/services/seo/seo.service'; import { buildCollectionPageSchema } from '../../core/services/seo/json-ld.util'; -import type { - ApiContent, - ApiListResponse, - ContentType, -} from '../../core/models'; - -const PER_PAGE = 50; -const CONTENT_TYPES: readonly ContentType[] = [ - 'article', - 'essay', - 'build-log', - 'til', - 'digest', -]; - -interface ContentsQuery { - type?: ContentType; - page: number; -} interface YearGroup { year: string; - items: ApiContent[]; + items: ArticleSummary[]; } /** - * The reading index — served at both `/` and `/articles`. One editorial - * list consolidating every written content type (article / essay / - * build-log / til / digest); the `type` query param narrows by type - * (the per-type lists are folded into this one index). + * The reading index at `/articles` — one editorial list of every published + * article, read from the generated index. */ @Component({ selector: 'app-articles', @@ -52,65 +32,32 @@ interface YearGroup { changeDetection: ChangeDetectionStrategy.OnPush, }) export class ArticlesComponent implements OnInit { - /** Query param: /articles?type=essay narrows the index to one type. */ - readonly type = input(); - - private readonly contentService = inject(ContentService); + private readonly publication = inject(PublicationService); private readonly seoService = inject(SeoService); - private readonly router = inject(Router); - - /** The canonical content types, exposed for the type filter row. */ - protected readonly contentTypes = CONTENT_TYPES; - protected readonly typeFilter = computed(() => { - const requested = this.type(); - return CONTENT_TYPES.includes(requested as ContentType) - ? (requested as ContentType) - : undefined; - }); - - /** Current page — snaps back to 1 whenever the type filter changes. */ - protected readonly page = linkedSignal({ - source: () => this.typeFilter(), - computation: () => 1, - }); - - protected readonly contentsResource = rxResource< - ApiListResponse, - ContentsQuery - >({ - params: () => ({ type: this.typeFilter(), page: this.page() }), - stream: ({ params }) => - this.contentService.listPublished({ - type: params.type, - page: params.page, - perPage: PER_PAGE, - }), + protected readonly articlesResource = rxResource({ + stream: () => this.publication.articles(), }); - protected readonly contents = computed(() => - this.contentsResource.hasValue() ? this.contentsResource.value().data : [], + protected readonly articles = computed(() => + this.articlesResource.hasValue() ? this.articlesResource.value() : [], ); /** - * contents() grouped by published year, newest year first; undated - * pieces sink into a trailing "—" bucket. Pure derivation — no query change. - * Page-scoped: server pagination is perPage:50, so the per-year counts - * reflect the current page, not corpus totals. Honest at the current corpus; - * once a single year exceeds one page, the counts should come from a server - * aggregate (flag, don't build). + * articles() grouped by published year, newest year first; undated pieces + * sink into a trailing "—" bucket. Pure derivation — no request. */ protected readonly grouped = computed(() => { - const byYear = new Map(); - for (const c of this.contents()) { - const year = c.published_at - ? new Date(c.published_at).getUTCFullYear().toString() + const byYear = new Map(); + for (const a of this.articles()) { + const year = a.published_at + ? new Date(a.published_at).getUTCFullYear().toString() : '—'; const bucket = byYear.get(year); if (bucket) { - bucket.push(c); + bucket.push(a); } else { - byYear.set(year, [c]); + byYear.set(year, [a]); } } return [...byYear.entries()] @@ -125,22 +72,15 @@ export class ArticlesComponent implements OnInit { }); protected readonly isLoading = computed( - () => this.contentsResource.status() === 'loading', + () => this.articlesResource.status() === 'loading', ); protected readonly loadError = computed( - () => this.contentsResource.status() === 'error', - ); - - protected readonly totalPages = computed(() => - this.contentsResource.hasValue() - ? this.contentsResource.value().meta.total_pages - : 0, + () => this.articlesResource.status() === 'error', ); ngOnInit(): void { - const description = - 'Every written piece — articles, essays, build logs, TILs, and digests.'; + const description = 'Every written piece.'; this.seoService.updateMeta({ title: 'Articles', @@ -154,22 +94,4 @@ export class ArticlesComponent implements OnInit { }), }); } - - protected setType(type?: ContentType): void { - void this.router.navigate(['/articles'], { - queryParams: type ? { type } : {}, - }); - } - - protected previousPage(): void { - if (this.page() > 1) { - this.page.update((p) => p - 1); - } - } - - protected nextPage(): void { - if (this.page() < this.totalPages()) { - this.page.update((p) => p + 1); - } - } } diff --git a/frontend/src/app/pages/home/home.html b/frontend/src/app/pages/home/home.html index 7164c7f5a..588604188 100644 --- a/frontend/src/app/pages/home/home.html +++ b/frontend/src/app/pages/home/home.html @@ -15,14 +15,7 @@

{{ statement }}

data-testid="home-lead" >