pyworklog 0.9.0__tar.gz → 0.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyworklog-0.9.0 → pyworklog-0.10.0}/.gitignore +4 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/AGENTS.md +2 -2
- {pyworklog-0.9.0 → pyworklog-0.10.0}/CHANGELOG.md +46 -1
- {pyworklog-0.9.0 → pyworklog-0.10.0}/CONTRIBUTING.md +19 -3
- {pyworklog-0.9.0 → pyworklog-0.10.0}/DESIGN.md +34 -5
- {pyworklog-0.9.0 → pyworklog-0.10.0}/DESIGN.zh.md +22 -2
- {pyworklog-0.9.0 → pyworklog-0.10.0}/Makefile +21 -15
- pyworklog-0.10.0/PKG-INFO +186 -0
- pyworklog-0.10.0/README.md +153 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/README.zh.md +33 -1
- pyworklog-0.10.0/demo/build_demo.sh +95 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/pyproject.toml +1 -1
- {pyworklog-0.9.0 → pyworklog-0.10.0}/skills/worklog-cli/SKILL.md +64 -8
- pyworklog-0.10.0/skills/worklog-cli/integrations/wl-cursor-session-start.sh +21 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/skills/worklog-cli/references/features.md +14 -0
- pyworklog-0.10.0/skills/worklog-cli/references/metrics.md +108 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/skills/worklog-cli/references/setup.md +41 -18
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/__init__.py +8 -1
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/cli.py +427 -300
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/commands/__init__.py +8 -1
- pyworklog-0.10.0/src/worklog/commands/admin.py +286 -0
- pyworklog-0.10.0/src/worklog/commands/agent_runtime.py +105 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/commands/alias.py +54 -18
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/commands/bulk.py +264 -53
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/commands/checkin.py +55 -26
- pyworklog-0.10.0/src/worklog/commands/dateinfo.py +159 -0
- pyworklog-0.10.0/src/worklog/commands/dtos.py +222 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/commands/goal.py +119 -72
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/commands/help.py +13 -14
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/commands/metric.py +119 -75
- pyworklog-0.10.0/src/worklog/commands/output.py +215 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/commands/query.py +531 -321
- pyworklog-0.10.0/src/worklog/commands/sched.py +298 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/commands/semantic.py +103 -83
- pyworklog-0.10.0/src/worklog/commands/state.py +2221 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/commands/views.py +288 -207
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/completion.py +3 -1
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/db.py +37 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/db_table.py +7 -0
- pyworklog-0.10.0/src/worklog/graph.py +511 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/add.md +1 -1
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/admin.md +3 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/agent.md +45 -24
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/apply.md +15 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/logs.md +1 -1
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/ls.md +1 -1
- pyworklog-0.10.0/src/worklog/help/en/output.md +47 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/query.md +4 -3
- pyworklog-0.10.0/src/worklog/help/en/reindex.md +28 -0
- pyworklog-0.10.0/src/worklog/help/en/relation.md +145 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/sched.md +9 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/show.md +6 -1
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/helpers.py +108 -12
- pyworklog-0.10.0/src/worklog/migrations/0012_promote_bare_date_logs.py +21 -0
- pyworklog-0.10.0/src/worklog/migrations/0013_relation_split_rename.sql +11 -0
- pyworklog-0.10.0/src/worklog/models.py +241 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/node.py +2 -2
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/node_schema.py +34 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/queries.py +194 -267
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/render.py +130 -12
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/timemodel.py +2 -1
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/timeutil.py +5 -4
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/conftest.py +19 -0
- pyworklog-0.10.0/tests/golden/cli_help.json +65 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_agent.py +93 -3
- pyworklog-0.10.0/tests/test_alive_lint.py +144 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_canceled.py +17 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_cascade.py +1 -1
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_checkin.py +58 -0
- pyworklog-0.10.0/tests/test_cli_help_golden.py +111 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_clock.py +102 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_config.py +56 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_cycle_safety.py +1 -1
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_day.py +47 -9
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_default_verb.py +40 -1
- pyworklog-0.10.0/tests/test_demo.py +53 -0
- pyworklog-0.10.0/tests/test_dev_build_guard.py +44 -0
- pyworklog-0.10.0/tests/test_dispatch_group.py +60 -0
- pyworklog-0.10.0/tests/test_graph_integrity.py +192 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_help.py +37 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_import_apply.py +263 -0
- pyworklog-0.10.0/tests/test_json_output.py +838 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_log.py +50 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_logs.py +25 -1
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_ls.py +3 -2
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_metric.py +11 -5
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_migrations.py +80 -2
- pyworklog-0.10.0/tests/test_output_formatter.py +218 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_projects.py +24 -1
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_query.py +57 -7
- pyworklog-0.10.0/tests/test_relation.py +574 -0
- pyworklog-0.10.0/tests/test_relation_claim.py +223 -0
- pyworklog-0.10.0/tests/test_relation_ready_deps.py +199 -0
- pyworklog-0.10.0/tests/test_render_helpers.py +61 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_sched.py +119 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_show.py +47 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_summary.py +70 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_type_props_create.py +43 -12
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_vectorstore.py +13 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/uv.lock +1 -1
- pyworklog-0.9.0/PKG-INFO +0 -220
- pyworklog-0.9.0/README.md +0 -187
- pyworklog-0.9.0/src/worklog/commands/admin.py +0 -145
- pyworklog-0.9.0/src/worklog/commands/dateinfo.py +0 -102
- pyworklog-0.9.0/src/worklog/commands/sched.py +0 -178
- pyworklog-0.9.0/src/worklog/commands/state.py +0 -1561
- pyworklog-0.9.0/src/worklog/help/en/reindex.md +0 -22
- pyworklog-0.9.0/src/worklog/help/en/relation.md +0 -58
- pyworklog-0.9.0/tests/test_relation.py +0 -308
- {pyworklog-0.9.0 → pyworklog-0.10.0}/.dockerignore +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/.github/workflows/release.yml +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/.github/workflows/test.yml +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/Dockerfile.dev +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/LICENSE +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/pytest.ini +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/skills/worklog-cli/integrations/statusline-wl.sh +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/skills/worklog-cli/integrations/wl-session-context.sh +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/skills/worklog-cli/references/bulk.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/commands/timenodes.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/config.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/embedding.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/active.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/agenda.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/alias.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/ancestors.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/area.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/body.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/bulk.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/cancel.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/changes.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/checkin.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/clock.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/config.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/date.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/dateinfo.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/day.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/defer.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/delete.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/descendants.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/done.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/find.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/focus.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/goal.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/ids.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/import.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/index.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/init.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/kind.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/link.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/log.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/metric.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/migrate.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/node.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/para.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/planned.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/planning.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/print-completion.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/priority.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/project.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/projects.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/prop.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/recap.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/relog.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/reopen.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/reporting.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/set.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/shortcuts.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/spent.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/start.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/status.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/stop.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/summary.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/tag.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/task.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/themes.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/tick.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/time.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/tracking.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/tree.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/types.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/unlink.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/unlog.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/unset.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/wait.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/help/en/window.md +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/migrations/0001_initial_schema.sql +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/migrations/0002_metric_and_log_tag.sql +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/migrations/0003_backfill_checkin_metrics.sql +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/migrations/0004_meta_props_to_typed_logs.sql +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/migrations/0005_clock_table.sql +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/migrations/0006_rename_log_type_to_tag.sql +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/migrations/0007_utc_timestamps.sql +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/migrations/0008_soft_delete_tombstones.sql +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/migrations/0009_tombstone_node_path_view.sql +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/migrations/0010_overview_top5_into_goal.sql +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/migrations/0011_drop_kind_column.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/node_type_backfill.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/node_types.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/templates/config.ini +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/vectorstore.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/src/worklog/xdg.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/__init__.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_add.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_alias.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_aliases.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_brief.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_changes.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_completion.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_dateinfo.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_db_table.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_embedding.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_embedding_config.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_filters.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_find.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_focus.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_goal.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_init.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_internals.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_link_set.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_node.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_node_type_backfill.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_node_types.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_plan.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_prop_clock.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_render.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_reserved_props.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_sample.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_state.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_timemodel.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_timeutil.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_tree.py +0 -0
- {pyworklog-0.9.0 → pyworklog-0.10.0}/tests/test_xdg.py +0 -0
|
@@ -39,9 +39,9 @@ wl --db /tmp/scratch.db add "..."
|
|
|
39
39
|
|
|
40
40
|
## Architecture (the big picture)
|
|
41
41
|
|
|
42
|
-
**
|
|
42
|
+
**Layered single package.** `src/worklog/` is split by concern: `cli.py` (~2000 lines) builds the argparse tree + dispatches; `commands/` (one module per command group) holds the handlers + `commands/dtos.py` view DTOs; `models.py` is one dataclass-per-table with CRUD over `db_table.py` (the low-level SQL helper — binding, the `ALIVE` soft-delete predicate, identifier validation); `queries.py` holds attribute/classification queries (the primitives — `classify_types`, `nodes_with_tag`, `node_type`, `make_node_filter`); `graph.py` is the **single source for graph operations** — everything that walks an edge of the node graph (tree via `parent_id`, the `relation.*` graph, spoke-table cascade delete): `_ancestors_chain` / `_collect_descendants` / `soft_delete_node` / `relation_view` / `_apply_relation` / `_project_members`. Import direction is one-way `queries ← graph ← commands` (graph imports queries primitives, never the reverse). It also owns `check_integrity` (`wl doctor`) — the read-only audit of the FK-less graph invariants (no dangling parent / cycle / orphan spoke / dead / one-sided / self-referential relation, date-only log timestamp); `render.py` / `node_schema.py` render; `node_types.py` is the orthogonal type system; `timeutil.py` / `timemodel.py` the time model; `vectorstore.py` / `embedding.py` semantic search; `migrations/NNNN_*` (`.sql`, a few `.py`) the schema; `__init__.py` re-exports `__version__`. (The early design was a single ~5000-line `cli.py`; it was split by concern as the surface grew.) **Data-access architecture** — the three-tier model layer, renderers taking `Node` not `sqlite3.Row`, and the **query-decomposition principle** (prefer simple single-table reads + Python compose + a command-scoped cache over complex JOIN/EXISTS/aggregate SQL; the DB is small enough that this is *faster* and far more maintainable) — is canonical in **DESIGN §26**.
|
|
43
43
|
|
|
44
|
-
**One polymorphic `node` table.** Classification is the orthogonal `type.*` prop namespace (`type.para` area/project/task · `type.date` lifetime…day · `type.habit` · `type.meetlog`); `parent_id` self-reference builds the tree. Two parallel sub-trees hang under `lifetime`: a **responsibility line** (`area → project → task`, PARA-style) and a **time line** (`year → quarter → month → week → day`, time skeleton + each level's `goal` / the day's `summary` reserved-tag logs). Day/week/month views are **log-driven** (`logged_at` + ancestor chain + tags), not via fixed parent-of-task — moving projects between areas does not break per-day views. Tables: `node / tag / log / prop / link / sched / date_meta` + recursive CTE view `v_node_path`.
|
|
44
|
+
**One polymorphic `node` table.** Classification is the orthogonal `type.*` prop namespace (`type.para` area/project/task · `type.date` lifetime…day · `type.habit` · `type.meetlog`); `parent_id` self-reference builds the tree. Two parallel sub-trees hang under `lifetime`: a **responsibility line** (`area → project → task`, PARA-style) and a **time line** (`year → quarter → month → week → day`, time skeleton + each level's `goal` / the day's `summary` reserved-tag logs). Day/week/month views are **log-driven** (`logged_at` + ancestor chain + tags), not via fixed parent-of-task — moving projects between areas does not break per-day views. Tables: `node / tag / log / metric / clock / prop / link / sched / date_meta` + recursive CTE view `v_node_path`.
|
|
45
45
|
|
|
46
46
|
**Path resolution.** Priority: `wl --db PATH` flag → `$WORKLOG_DB` env → `$XDG_DATA_HOME/worklog/worklog.db` (default `~/.local/share/worklog/worklog.db`). Config is `$XDG_CONFIG_HOME/worklog/aliases.ini`. `_resolve_db_path(args)` is the only resolver; `cmd_config` displays which branch fired.
|
|
47
47
|
|
|
@@ -6,6 +6,50 @@ The autogenerated `Commits since vX.Y.Z` block on each GitHub Release captures e
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.10.0] - 2026-07-10
|
|
10
|
+
|
|
11
|
+
### Breaking
|
|
12
|
+
- **`wl relation` — `split-from`/`split-into` collapse into one `split` type, single-write.** `wl relation A split B` records "A split out B", stored ONLY on A; the old dual-write (writing the inverse on B too) is gone — every relation type (`split`, `related`) is now single-write, with the reverse (`=split-from` / a one-sided `related` folded into `=backrels`) derived at read time, never stored, so it can never go stale. `wl show -o json`'s `relations` object changes shape accordingly (`split`/`related`/`split_from`, was `split_from`/`split_into`/`related`). **Migration 0013** renames existing `relation.split_into` props to `relation.split` and drops the now-redundant `relation.split_from`; `related` needs no migration. `wl doctor`'s `asymmetric_relation` check is removed — one-sidedness is the norm now, not dirt.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **`wl apply` (wl-diff) — `goal` / `summary` / `+metric` / `sched` / `recur` field-ops.** `~ #id` gains `goal <text>` / `summary <text>` (the declarative `wl goal set` / `wl recap`, a reserved-tag log), `+metric tag [value] [unit]` (a node-level datapoint; bare tag = a marker like `wl tick`), `sched <date>` / `recur <rule>` / `-sched` (a PRECISE sched-table row / rrule — the declarative `wl sched`; distinct from `scheduled`, which only sets the rough `scheduled_date` column), `spent <duration>` (a completed clock — declarative `wl spent`; live `start`/`stop` stay command-only), and `body <text>` (the node column wl-diff couldn't reach). `+` add gains an `@metric` sub-line. **New `#L` log target**: `~ #L<id>` rewrites one existing log (`body` / `retag` / `at` — declarative `wl relog` / `wl retag`) and `- #L<id>` deletes it (`wl unlog`). **New top-level `@date <date> <label>`** writes a date label (declarative `wl dateinfo`; a `-` label clears). Together these close the gap: every mutation except a *live* clock (`wl start`/`stop`, imperative by nature) is now expressible in a wl-diff. Part of extending apply to cover the full mutation surface (the AI's declarative CRUD).
|
|
16
|
+
- **`wl demo`** — seed a small "a day with wl" sample dataset (two projects, today's scheduled tasks, a goal, a log, a done, a parked idea, an evening recap, tomorrow's focus) so a newcomer can explore a populated db right away. Runs ONLY on a completely empty database and refuses otherwise, so it can never touch real data — point `$WORKLOG_DB` at a throwaway db first. Drives the real command handlers, so the result is exactly what a user would build by hand (the same story `demo/build_demo.sh` records for the README gif).
|
|
17
|
+
- **`wl relation` — new `block` dependency type.** `wl relation A block B` records "A blocks B" (B can't start until A is done), single-write like `split`/`related`, with the reverse derived as `=blocked-by`. Unlike `split`/`related`, `block` is cycle-checked at write time: an edge that would close a dependency loop is rejected with a clear error instead of being written. `wl show -o json`'s `relations` object gains `block`/`blocked_by` fields.
|
|
18
|
+
- **`wl show` — `=ready`/`=waiting` computed fields.** For a node that participates in a `block` relation (blocks something or is blocked by something), the `relations:` block now also shows `=ready` (is every direct blocker done/terminal?) and `=waiting` (the direct blockers still open; empty when ready) — same fields `wl relation ready` computes, so they can never disagree. Omitted entirely for a node with no `block` edge. `-o json`'s `relations` object gains `ready`/`waiting` keys under the same condition; `wl relation <id>`'s own output is unaffected (still the pure stored+derived contract).
|
|
19
|
+
- **`wl relation` restructured into an entity group** (`set`/`ready`/`deps`/`unclaimed`/`claim`/`unclaim`, like `tag`/`link`/`sched`/`agent`) — `set` is the default verb, so the existing `wl relation <id> …` form keeps working unchanged.
|
|
20
|
+
- **`wl relation ready <id> [--chain]`** — is `<id>` ready to work on (all its direct `block` blockers done/terminal), plus which of its block-graph downstream tasks just became unblocked; `--chain` adds the full upstream chain for context. Always needs an anchor id (most nodes carry no `block` relation, so a global scan would drown the few that matter).
|
|
21
|
+
- **`wl relation deps [<root>]`** — the full cascaded `block` dependency graph (`<root>` + everything it transitively blocks, or every node participating in any `block` edge if no root), each entry with its own `blocks` list and live ready/blocked state — unlike `wl show`'s direct-layer-only `=blocked-by`, this walks the whole chain.
|
|
22
|
+
- **`wl relation unclaimed [<root>]`** — open (non-terminal) tickets with no active claim (never claimed, or claim gone stale), scoped to `<root>`'s tree subtree if given, else global; intersect with `ready` for the wayfinder-style frontier (ready ∩ unclaimed).
|
|
23
|
+
- **`wl relation claim` / `wl relation unclaim`** — exclusive ticket claim, orthogonal to `block`/`ready` (claim = who's working it, not whether it can be worked). Identity defaults to `<agent>:<session id>` (same derivation as `wl agent`); `--as IDENTITY` overrides with a free string. Stored as plain `claimed_by`/`claimed_at` props (not `relation.*`). Fails if held by someone else and still fresh (<24h); a stale claim is free for anyone to take over or release, no `--force` needed; releasing your own or an unclaimed ticket always succeeds, `--force` overrides someone else's fresh claim.
|
|
24
|
+
- **Cursor agent session binding** — `wl agent` auto-detects `$CURSOR_CONVERSATION_ID` / `$CURSOR_AGENT=1` (stores `agent_session.cursor`); `wl agent context --hook cursor` emits a `sessionStart` JSON payload; ships `integrations/wl-cursor-session-start.sh` for `~/.cursor/hooks.json`.
|
|
25
|
+
- **Forgotten-clock backstop** — `wl active` and `wl day` now auto-close any open clock running longer than 12h (capped at 12h, reported), so a forgotten `wl start` never times forever or silently loses its whole session. Below that, `wl active` warns on a clock running >6h ("forgot to stop?") with the correction command.
|
|
26
|
+
- **`wl ls --not-checked-in N`** — list recurring items (have an rrule) not checked in within the last N days, or never — the "what have I been neglecting" view. Computed read-time from check-in metrics (no cached state to go stale).
|
|
27
|
+
- **`wl show` now shows a recurring item's last check-in date** — `last check-in: 2026-06-25` (or `never`), computed read-time from the check-in metrics.
|
|
28
|
+
- **`wl sched stop <id> [date]`** — end a recurrence: it fires up to and including `date` (default today), then stops; past occurrences stay intact (unlike `--clear`, which erases the rule from history). Encodes an inclusive `;until=<date>` on the rrule; `--rule` targets one of several rules. `wl show` renders it as `recur daily (stopped 2026-06-30)`.
|
|
29
|
+
- **`wl doctor`** — scan the node graph for inconsistencies no foreign key prevents (dangling `parent_id`, parent cycles, orphaned spoke rows, `relation.*` refs to dead nodes, one-sided or self-referential relations, date-only log timestamps). Read-only; reports by kind + node. `-o json` for structured output.
|
|
30
|
+
- **`-o json` global flag** — works on every command; place before or after the verb. Write commands return structured data (row dicts / deleted-id lists / updated lists); human-readable confirmation lines are suppressed.
|
|
31
|
+
- **`wl help output`** — new help topic for `-o json` with return shapes and examples.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- **Terminal-status check is now one shared constant** — `TERMINAL_STATUSES` (`helpers.py`) replaces the `('DONE', 'CANCELED')` tuple that was duplicated across list/agenda/goal-progress filtering and the add-time duplicate-task check.
|
|
35
|
+
- **Agent runtime support is now registry-driven** — runtime-specific session env / marker / hook payload lives in one `AgentRuntime` registry (`commands/agent_runtime.py`), so adding a new tool is one entry instead of touching handler logic in multiple places.
|
|
36
|
+
- **`wl day` marks any recurring item `[x]` when checked in that day, not just habits** — the completion marker used to be gated on `type.habit`; a recurring task (`wl sched --recur` + `wl tick`) now shows `[x]` too, so completion marking is unified across all recurring items. The node's DB status is untouched (still recurring).
|
|
37
|
+
- **`wl reindex` is now incremental by default** — on first run (no index yet) it does a full build automatically; on subsequent runs it embeds only new/changed nodes and evicts deleted ones. Use `--full` to force a full rebuild (e.g. after switching embedding models). The old `--incremental` flag is removed.
|
|
38
|
+
- **`wl summary` leads with the window's goal** — scoped to one time node (`--week`/`--month`/`--quarter`/`--year`), it shows that node's goal + `[done/total]` + target nodes up top (the `wl day` header, generalized to every level).
|
|
39
|
+
- **`wl show <node>` shows the node's goal** — any node with a goal (a time node or a project/task) gets the `🎯` marker + `[done/total]` + target nodes, the same block as the `wl day` header.
|
|
40
|
+
- **`--quarter YYYY-Qn` / `--year YYYY` window flags** — two coarser grains on the shared window parser (`summary`/`logs`/`changes`/`projects`).
|
|
41
|
+
- **ISO period markers are case-insensitive on input** — `2026-q2` / `2026-w05` work everywhere a quarter/week is typed (window flags, `wl sched`/`defer`); canonical storage stays uppercase.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
- **`wl day` month goal resolved by the day's date, not its week ancestry** — a cross-month week no longer surfaces the previous month's Top5 for its spill-over days.
|
|
45
|
+
- **Malformed window flags die cleanly** — `wl summary --week 2026-07` gave a raw `ValueError`; every window flag now validates and dies with a clear `expected YYYY-Www` hint.
|
|
46
|
+
- **`wl logs --week/--month/--quarter/--year` now filter** — `wl logs` ignored every period window flag (read `--since` directly, skipping `_resolve_window`) and silently returned the default 7-day window; it now resolves them like `summary`/`changes`.
|
|
47
|
+
- **`wl done` / `wl cancel` / `wl tick --done` now close an open clock** — a terminal status used to leave the running clock open, so it kept "timing" after the task was closed (and lingered as running in `wl active`). All terminal/suspend paths now share one `_close_open_clocks` helper (previously only `wl wait` closed it).
|
|
48
|
+
- **`wl active` no longer overcounts today's total across midnight** — a session started before midnight added its pre-midnight minutes to *today's* total; the live session is now clipped to the local day.
|
|
49
|
+
- **`wl log --date` no longer drops the time** — a backfilled log used to store a bare date (`2026-06-24`), losing intra-day ordering and rendering no `@HH:MM`. It now stamps the current wall-clock time on that date (a full instant), while day-grouping still lands on the given day, and prints a hint that the time was defaulted to now (add `--time HH:MM` to set it). `wl metric add --at <date>` got the same fix for its carrier log. **Migration 0012** promotes any existing bare-date logs to that day's local midnight; `wl doctor`'s `bare_timestamp` check flags any that remain.
|
|
50
|
+
- **`make install` now installs from a clean committed HEAD** via a temporary git worktree (`uv tool install`), keeping the daily `wl` isolated from the dev `.venv`; refuses to run with uncommitted changes.
|
|
51
|
+
- **A dev/source checkout refuses to touch the real database** — running `wl` from a working tree (a `.git` + `pyproject.toml` root) against the default db now fails closed with an explanatory error instead of silently auto-applying an uncommitted migration and corrupting live data; set `WORKLOG_DB` to a scratch db for dev, or to the default path explicitly to override.
|
|
52
|
+
|
|
9
53
|
## [0.9.0] - 2026-06-18
|
|
10
54
|
|
|
11
55
|
### Breaking
|
|
@@ -238,7 +282,8 @@ Initial open-source release of `worklog-cli` (`wl`).
|
|
|
238
282
|
- **Vault integration** — `wl link` ↔ Obsidian docs (`[[doc]]`).
|
|
239
283
|
- Bilingual docs (EN + 中文); MIT License.
|
|
240
284
|
|
|
241
|
-
[Unreleased]: https://github.com/xyb/worklog/compare/v0.
|
|
285
|
+
[Unreleased]: https://github.com/xyb/worklog/compare/v0.10.0...HEAD
|
|
286
|
+
[0.10.0]: https://github.com/xyb/worklog/compare/v0.9.0...v0.10.0
|
|
242
287
|
[0.9.0]: https://github.com/xyb/worklog/compare/v0.8.0...v0.9.0
|
|
243
288
|
[0.8.0]: https://github.com/xyb/worklog/compare/v0.7.0...v0.8.0
|
|
244
289
|
[0.7.0]: https://github.com/xyb/worklog/compare/v0.6.0...v0.7.0
|
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Thanks for considering a contribution! This guide covers the local setup, the development loop, and the two coding principles every change is held to: **TDD** and **DRY**.
|
|
4
4
|
|
|
5
|
+
## Schema
|
|
6
|
+
|
|
7
|
+
Six tables; everything is a `node`.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
node (id, parent_id→node, title, status, priority,
|
|
11
|
+
created_at, scheduled_at, deadline_at, closed_at, body)
|
|
12
|
+
tag (node_id→node, tag) # many-to-many
|
|
13
|
+
log (id, node_id→node, logged_at, body) # one node, many log entries
|
|
14
|
+
prop (node_id→node, key, value) # UDA
|
|
15
|
+
link (node_id→node, vault_doc) # vault wikilink
|
|
16
|
+
v_node_path # recursive CTE view, tree path
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
One `node` table holds every execution-system entity; classification is the orthogonal `type.*` prop namespace (no dedicated column). Cascade delete propagates to `tag/log/prop/link`; `parent_id` uses `ON DELETE SET NULL` so deleting a parent doesn't orphan-kill children. Full schema: `src/worklog/schema.sql`.
|
|
20
|
+
|
|
5
21
|
## Prerequisites
|
|
6
22
|
|
|
7
23
|
- [uv](https://docs.astral.sh/uv/) — install via `brew install uv` or `pipx install uv`.
|
|
@@ -12,11 +28,11 @@ Thanks for considering a contribution! This guide covers the local setup, the de
|
|
|
12
28
|
```fish
|
|
13
29
|
git clone https://github.com/xyb/worklog.git ~/projects/worklog
|
|
14
30
|
cd ~/projects/worklog
|
|
15
|
-
make
|
|
31
|
+
make sync # uv sync --all-groups → creates .venv/ for dev/test
|
|
16
32
|
make test # run the full suite (parallel, 95% cov gate)
|
|
17
33
|
```
|
|
18
34
|
|
|
19
|
-
`make
|
|
35
|
+
`make sync` materializes `.venv/` from `pyproject.toml` + `uv.lock`. From there, every command (`uv run pytest …`, `make test`, etc.) uses the same locked deps. To install `wl` for daily use, run `make install` separately — it builds a frozen snapshot from a clean committed ref, isolated from this dev `.venv`.
|
|
20
36
|
|
|
21
37
|
## Day-to-day development loop
|
|
22
38
|
|
|
@@ -119,7 +135,7 @@ The codebase has a small set of single-source helpers; new code must reuse them,
|
|
|
119
135
|
- `_status_marker(status)` — status → `[ ]/[x]/[/]/...` marker. Never hard-code the marker elsewhere.
|
|
120
136
|
- `_node_line(con, n, ...)` — the **only** node renderer. Any place listing nodes reuses it and inherits highlighting + search emphasis for free. Hand-rolling a node line is rejected in review.
|
|
121
137
|
- `out()` / `_c(text, style)` — the single output pipe. Bare `print()` for renderable content is rejected; any fragment that might contain `[ ]` must go through `_c` so `rich.markup` doesn't eat it.
|
|
122
|
-
- `_resolve_window(args)` — time-window flag resolution
|
|
138
|
+
- `_resolve_window(args)` — time-window flag resolution (`--since` / `--until` / `--week` / `--month` / `--quarter` / `--year`). Commands must not parse these themselves; gate "was a window given" on `_window_period(args)`, not a hand-listed flag tuple.
|
|
123
139
|
- `_resolve_db_path(args)` — DB path resolution (`--db` flag > `$WORKLOG_DB` > XDG default). Mirrored by `__wl_db_path[_bash|_zsh]` helpers in the generated shell completion — keep them in sync.
|
|
124
140
|
|
|
125
141
|
Same rule for documentation: install / dev / release info lives here, not duplicated in README. Sections in README that need this content link to it instead of restating it.
|
|
@@ -122,7 +122,12 @@ The one **clean group** (no collision, so no default verb) is **`date`** (the `d
|
|
|
122
122
|
table): `date set / ls / rm / import`, with `wl dateinfo` as the polymorphic everyday
|
|
123
123
|
shortcut (sets when given a label, lists when not, `--clear` / `--import` variants).
|
|
124
124
|
|
|
125
|
-
The `<entity> <verb>` reshape is now **complete** for every entity.
|
|
125
|
+
The `<entity> <verb>` reshape is now **complete** for every entity. Every group's dispatcher is
|
|
126
|
+
the same shape, so it routes through one shared helper — `render.dispatch_group(args, con, attr,
|
|
127
|
+
table, usage=, default=)`: `attr` is the argparse dest holding the sub-verb, `table` maps sub-verb
|
|
128
|
+
→ handler, a bare group runs `default(args, con)` (e.g. agent's *show*, goal's *today*) or dies
|
|
129
|
+
with `usage`. A new group is a routing table plus one call; the 4-line per-group boilerplate (and
|
|
130
|
+
metric's old `_SUBS`) is gone — the convention lives in the helper, not in eleven copies.
|
|
126
131
|
|
|
127
132
|
**B. Composite helper** — a one-step shortcut that wraps one or more primitives for
|
|
128
133
|
the common path; never the *only* way to do something (the primitive stays). Status
|
|
@@ -168,7 +173,7 @@ A single `node` table carries everything; classification is the orthogonal `type
|
|
|
168
173
|
- **classification values** (orthogonal `type.*` props, no single column): `type.para` = PARA role `area / project / task`; `type.date` = time level `lifetime / decade / year / quarter / month / week / day`; `type.habit`; `type.meetlog`; custom `type.<x>` (extensible, but a new type should have a clear place in tree / projects / summary classification). A node may hold several at once; a single representative token is derived by `node_type` / `node_type_from_props` (precedence para > date > habit > meetlog > custom > task)
|
|
169
174
|
- **status only applies to task / habit / meetlog**; time-hierarchy / area / project nodes leave status NULL
|
|
170
175
|
- Tables: `node / tag / log / metric / clock / prop / link / sched / date_meta` + derived view `v_node_path`
|
|
171
|
-
- **`node → log → metric` spine** (the log-centric core): a `node` has many `log`s; a `log` (carrying a `tag` — `note`/`goal`/`summary`/`metric`(carrier)/… , NULL = plain note) has 0..N `metric`s. A `metric` is a structured datapoint (`tag` = what it is e.g. `glucose`/`pullups`/`checkin`, or `goal` = a goal log's priority-ordered target node ids; `value_num`/`value_text`/`unit`/`note`/`at`). **`tag` is the uniform classification field across all three** — node (a multi-value label set), log (its role, single-value), metric (its type, single-value); same word, different scopes, SQL-unambiguous. A metric **must hang off a log** (`metric.log_id` NOT NULL) — so every datapoint has a log carrier; a `CHECK` requires a value (pure markers store `value_num=1`). `metric.node_id` is denormalized for join-free per-node queries (no FK; triggers keep it equal to the carrier log's node). CRUD surface: `wl metric add/ls/edit/rm` (`add` without `--on-log` creates a carrier log; a value-less marker is stored as `value_num=1`); `--metric` on `wl log`/`wl add` and `metrics` in `wl import` attach datapoints inline.
|
|
176
|
+
- **`node → log → metric` spine** (the log-centric core): a `node` has many `log`s; a `log` (carrying a `tag` — `note`/`goal`/`summary`/`metric`(carrier)/… , NULL = plain note) has 0..N `metric`s. A `metric` is a structured datapoint (`tag` = what it is e.g. `glucose`/`pullups`/`checkin`, or `goal` = a goal log's priority-ordered target node ids; `value_num`/`value_text`/`unit`/`note`/`at`). **`tag` is the uniform classification field across all three** — node (a multi-value label set), log (its role, single-value), metric (its type, single-value); same word, different scopes, SQL-unambiguous. A metric **must hang off a log** (`metric.log_id` NOT NULL) — so every datapoint has a log carrier; a `CHECK` requires a value (pure markers store `value_num=1`). `metric.node_id` is denormalized for join-free per-node queries (no FK; triggers keep it equal to the carrier log's node). CRUD surface: `wl metric add/ls/edit/rm` (`add` without `--on-log` creates a carrier log; a value-less marker is stored as `value_num=1`); `--metric` on `wl log`/`wl add` and `metrics` in `wl import` attach datapoints inline. A recurring item's "done today" = a `tag=checkin` metric that day (written by `wl tick`/`wl checkin`), not "any log exists". `wl day` renders `[x]` for **any recurring item** with a check-in that day — habit *or* rrule-scheduled task alike — so completion marking is unified across recurrence types. Gated on recurring (`is_habit or _has_rrule`) so a one-off task `wl tick`'d for a quick note (no `--done`, still TODO) is not falsely marked done.
|
|
172
177
|
- **Design rule — keep `log` thin; new per-log data goes on a `metric`, not a new log column.** The `log` table stays a small fixed shape (`node_id`/`logged_at`/`body`/`tag`). When some feature wants to attach structured data to a log event (a measurement, an id, a category, a flag), the answer is a `metric` hanging off that log — never a widened `log` row. A metric is already typed (`tag` + `value_num`/`value_text`/`unit`/`note`/`at`), queryable (`wl metric ls --tag …`), and append-only, so it carries arbitrary per-event fields without bloating every log. Example: `wl agent --record` records a bind as **two metrics on one carrier log** — `agent_session` (`value_text` = full sid) and `agent` (`value_text` = the runtime name — claude / cursor / codex, from `$WL_AGENT` / `--agent`) — not as `log.session_id` / `log.agent` columns. Each value is its own queryable datapoint (`wl metric ls <id> --tag agent_session` / `--tag agent`). This keeps the log body human-readable and the schema stable (G3). The live pointer is the `agent_session.<agent>` prop (the key suffix carries the agent too); session lookups match `key LIKE 'agent_session.%'` since the sid alone is unique. (The metric tag string `agent_session` mirrors the prop namespace; the Python constant is `_SESSION_METRIC_TAG`.)
|
|
173
178
|
- **Decision rule — where does a fact go: `prop` vs `metric` vs `log`?** Sort by *"will I filter / group / aggregate over it across nodes?"* and by *cardinality*:
|
|
174
179
|
- **`prop`** — a **single-value query dimension** (`PRIMARY KEY (node_id, key)` → one value per key, overwrite-in-place). Use it for the few attributes you slice the whole tree by: `owner`, `project`, `linear-id`, or **the one identifying ref a task maps to** (the release it shipped in → `release=v0.7.0`, so "which tasks shipped in v0.7.0" is one `prop` query; the single PR a one-shot task *is*). Props are scarce and high-value — don't flood the key-space with process noise.
|
|
@@ -176,10 +181,11 @@ A single `node` table carries everything; classification is the orthogonal `type
|
|
|
176
181
|
- **`metric`** — when you want those process records **structured / queryable per node** (a series), hang them off the log: `tag=commit value=<hash>` queried with `wl metric ls <id> --tag commit`. Many-per-node, append-only, time-stamped — process-grade, **not** a cross-node filter dimension.
|
|
177
182
|
- Worked example (commit / PR / release ↔ task): the **many** intermediate commits of a dev task → `log` (add a `commit` **metric** if you want them structured); a task that maps to **one** identifying PR/commit, or its **release** → `prop` (so it's filterable/countable). The dividing question is value-to-query, not "can it physically fit".
|
|
178
183
|
- **Namespaced prop keys (`group.member`)** — a prop key may use a dot to group related single-value props under a shared prefix (`agent_session.claude` / `agent_session.cursor`; external ids per system `ext.linear` / `ext.github`). Each full key is still one single-value prop (`PRIMARY KEY (node_id, key)`); the namespace groups *sibling* keys — it does **not** make a key multi-valued. The convention's payoff is **prefix lookup**: one `key LIKE 'group.%'` finds every member across nodes, so prop-based features (filter / summary / stats) operate on a whole namespace, not just an exact key. **All prop query surfaces must honor it**: exact key (`k=v`), key existence (`k`), and namespace prefix (`group.` / `group.*`). Reserve it for a logical dimension with several named slots; keep flat keys (`owner`, `release`) flat. First user: `wl agent` (the live binding is the `agent_session.<agent>` prop; sid lookups match `key LIKE 'agent_session.%'`).
|
|
179
|
-
- **Task↔task relations are `relation.*` props (`wl relation`)** — derivation / association *across* the tree (this task
|
|
184
|
+
- **Task↔task relations are `relation.*` props (`wl relation`)** — derivation / association / dependency *across* the tree (this task blocks that one / split out that one / relates to that one), distinct from **ancestors** (the parent/child hierarchy). Stored as namespaced props `relation.block` / `relation.split` / `relation.related`, each a **comma-separated id list** (a relation is naturally many-per-node, so the value is multi-valued — the one place a prop value holds a list rather than a single scalar). **Single-write, source-stores model**: `wl relation A block B` / `wl relation A split B` writes ONLY on A (A blocks B / A split out B); the reverse is NEVER stored, only derived at read time by `relation_view` (`graph.py`), so it can never go stale — `=blocked-by` / `=split-from` on B are computed fresh every read. `related` is single-write too, but symmetric in meaning: `A related B` and a separate `B related A` are two independent edges, neither implied by nor deduped against the other (both are real, both display); a one-sided `related` edge folds into `=backrels` on the other side rather than getting its own derived label, since "A related me" and "a text `#id` mention" are the same kind of inbound, machine-derived fact. `block` is the one type checked for cycles at write time — `_apply_relation` walks the existing block graph forward from the would-be target and raises `RelationCycleError` (surfaced via `die()`) if it already reaches the would-be source, since a dependency edge closing a loop would leave two tasks each permanently waiting on the other; `split`/`related` are exempt (no logic depends on either forming a DAG). `wl show` renders a dedicated `relations:` block — stored edges plain, derived ones marked with a leading `=` + italic (same convention as `=backrels`) — and `-o json` a resolved `relations` object (`block`/`split`/`related`/`blocked_by`/`split_from`) plus the top-level `backrels` array. The raw `relation.*` props are hidden from the generic props block to avoid double display. Why a prop and not a `link`-style table: it rides the existing namespace + `wl ls --prop relation.` query machinery for free (G3) — no new table or join.
|
|
185
|
+
- **`wl show` also computes `=ready`/`=waiting` on top of `block`** (`graph.node_ready_view`) — `=ready` (bool: every direct blocker done/terminal) and `=waiting` (the direct blockers still open; empty when ready), appended to the SAME `relations:`/`relations` block right after `=backrels`. Shown ONLY when the node participates in the block graph at all (blocks something or is blocked by something) — a node with no `block` edge gets neither field, same convention as an empty relation section being omitted. This is a `wl show`-only computed extra: `wl relation <id>`'s own CRUD/list output (text or `-o json`) stays the pure stored+derived-reverse contract, unchanged — readiness has its own dedicated query (`wl relation ready`) for when that's the actual ask. Built on the exact same `_block_graph`/`node_is_ready`/`node_waiting_on` primitives as `wl relation ready`/`deps` (`graph.py`), so all three views can never disagree about whether a node is ready.
|
|
180
186
|
- **A goal's structured targets are `goal` metrics on the goal log** — a `goal` log (goal/summary are `log` rows with a reserved `tag`) can carry, supplied **explicitly at write time** (`wl goal "ship X" 12 34` / `wl goal set <node> "..." 7 9 3`), the task/project node ids it aims to deliver — stored as one `goal` metric per id on the log (`metric.tag='goal'`, `value_num=node id`, **insertion order = priority**, any count). wl **never parses them from the prose** (text extraction is too fragile); omit them and the goal is plain free text. The metric tag is the bare word `goal` (same as the carrier log's tag) — no per-level suffix, because the carrier log's node type (day/week/month/year) already says the level. So the plan↔node link is **queryable** off the metric table (`tag='goal'`, `value_num=<id>` = which goals target a node; narrow by node type for the level) rather than buried in prose, and history-preserving (each goal edit appends a log with its own target snapshot; `_log_goals` reads the latest). Reuses the existing `metric` entity — no new table.
|
|
181
187
|
- **Deliberate design — `wl add` / `wl log` stamp the current local time in their success line** (`✓ #1 task '…' @2026-06-09 20:09`). Intentional, primarily an **AI affordance**: an assistant's sense of "today" drifts to the date its session started and goes stale over a long session, so it mis-dates backfills and reasoning. Echoing the real wall-clock on every content-creating command re-anchors the caller's clock to *now*. (`local_now()`, minute precision.)
|
|
182
|
-
- **Reserved-tag logs are history-preserving**: `goal` (forward, any time level) and `summary` (backward recap) are `log.tag` logs (latest = current; each edit appends), with their own `wl goal set/ls/rm` CRUD group (`--summary` flag targets the summary); also written by bare `wl goal`/`wl recap` (today-auto) and the key-routed `wl set <node> <key>` shortcut. A goal is the **same `goal` tag at every level** — the node's type (day/week/month/year) is the level; the former week `overview` and month `top5` collapsed into it (migration 0010). (`prop` is back to truly-static single-value attributes — a separate store.) In the `wl day` header the day's goal + recap plus the ancestor week's & month's goal each render with a distinct marker (`_DAY_MARKERS`: 🎯 goal · 📝 recap · 📅 this week · ⭐ this month) through `_header_blockquote`, which prefixes only the FIRST line with `> ` and aligns every continuation (soft-wrap / embedded newline) with plain spaces under the `> ` content column (cleaner than repeating `> `), wrapping by display width like `_node_line`. Each shown goal then lists its structured target nodes below it — numbered, status-marked (`_emit_goal_targets
|
|
188
|
+
- **Reserved-tag logs are history-preserving**: `goal` (forward, any time level) and `summary` (backward recap) are `log.tag` logs (latest = current; each edit appends), with their own `wl goal set/ls/rm` CRUD group (`--summary` flag targets the summary); also written by bare `wl goal`/`wl recap` (today-auto) and the key-routed `wl set <node> <key>` shortcut. A goal is the **same `goal` tag at every level** — the node's type (day/week/month/year) is the level; the former week `overview` and month `top5` collapsed into it (migration 0010). (`prop` is back to truly-static single-value attributes — a separate store.) In the `wl day` header the day's goal + recap plus the ancestor week's & month's goal each render with a distinct marker (`_DAY_MARKERS`: 🎯 goal · 📝 recap · 📅 this week · ⭐ this month) through `_header_blockquote`, which prefixes only the FIRST line with `> ` and aligns every continuation (soft-wrap / embedded newline) with plain spaces under the `> ` content column (cleaner than repeating `> `), wrapping by display width like `_node_line`. Each shown goal then lists its structured target nodes below it — numbered, status-marked (`_emit_goal_targets`) — and a ` [done/total]` achievement tag (`_goal_progress`/`_goal_counts`: counts the structured targets' DONE/CANCELED, falling back to prose `#id`/`WL#id` refs when a goal has no structured targets; ✅/🟡/⬜). One row of that — marker + goal + `[done/total]` + targets — is the single source `_emit_goal_block(con, node, marker)`, so **every** goal surface renders identically: the `wl day` header (day/week/month rows), `wl summary --week/--month/--quarter/--year` (the window's time node), and `wl show <node>` for **any** node carrying a goal (a time node or a project/task, marker 🎯).
|
|
183
189
|
- **`clock` is structured time tracking**: `clock(node_id, start_at, end_at, elapsed_sec)`, written by `wl start`/`stop`/`spent`/`wait` — replaces the old `CLOCK_IN`/`CLOCK_OUT` log-body convention. Durations are summed from `elapsed_sec`, not parsed from text.
|
|
184
190
|
- **Two parallel trees, both hung under lifetime**:
|
|
185
191
|
- **Responsibility line**: `lifetime → area → project → task` (PARA model: area is a cross-time responsibility domain, projects belong to areas, tasks belong to projects)
|
|
@@ -230,6 +236,7 @@ Anywhere "list a node" — use the uniform line format (`_fmt_node` / summary's
|
|
|
230
236
|
- `⏱<N>min`: CLOCK time accumulated on the node (`_node_clock_min`), shown when > 0
|
|
231
237
|
- Completion lists use `✓` in place of the marker
|
|
232
238
|
- **The only renderer is `_node_line`**, which threads through `_c` for coloring (see §19). Any place listing nodes reuses it and gets highlighting for free; do not hand-roll the string elsewhere.
|
|
239
|
+
- **The surrounding render surfaces have single sources too.** A `▸ ` group-section header (the `wl day` sections, `--by project/direction`, `changes`, `summary`, `projects`) goes through `_group_header(title, *, style=, pri=, node_id=, suffix=, lead=)` — it owns the glyph, spacing, and id/priority/suffix styling; the caller passes only its semantic parts and the suffix WORDING (`"(done 3 / pending 2)"`). A `· <body>` log line under a node goes through `_log_body_row(body, indent, full=)`, which derives `indent_cols = len(indent) + 2` for the `· ` glyph (the relationship the call sites used to hand-compute). And the `(showing N/total[, extra])` truncation hint is `_print_truncation_hint` — the `extra` carries the per-command advice (`--limit N to adjust …`); don't re-spell the `showing` wording inline.
|
|
233
240
|
- **The priority slot always shows a 4-column marker via `_pri_marker` (single source).** Set → `[#A]`/`[#B]`/`[#C]` (priority-colored); **unset → a muted `[# ]`**, never blank and never `[ ]`. Blank spaces (the old placeholder) were 3 columns — one short of `[#A]` — so unset rows mis-aligned; `[ ]` collides with the TODO status marker and reads as a checkbox. `[# ]` keeps every row's columns aligned and says "priority not set" unambiguously. Every list/header that shows priority (`ls`/`day`/`tree`/`projects`/`summary`/`focus`/`show`/running-clock/check-in) routes through `_pri_marker` — do not re-derive the blank/`[ ]` form anywhere.
|
|
234
241
|
|
|
235
242
|
## 7. Timeline / changes / CLOCK (`wl show`)
|
|
@@ -248,6 +255,8 @@ A node's "what happened" = created / scheduled / closed / individual logs, merge
|
|
|
248
255
|
- `wl start` writes a log with `body=CLOCK_IN`; `wl stop` writes `CLOCK_OUT elapsed=Nmin (from ...)`
|
|
249
256
|
- CLOCK_* logs are not "progress logs" (filtered out by changes / summary)
|
|
250
257
|
- Time accumulation = sum of all `elapsed=Nmin` from CLOCK_OUT logs (`_node_clock_min`)
|
|
258
|
+
- **Open-clock lifecycle.** An open clock (`end_at NULL`) is closed by `wl stop`, and by every terminal/suspend status — `wl done` / `cancel` / `wait` / `tick --done` — through the single `_close_open_clocks` helper, so a closed task never keeps timing. **Forgotten-clock backstop:** since an open clock contributes 0 to any day total (its `elapsed_sec` is NULL until stop), a forgotten `wl start` would silently lose its whole session; `wl active` and `wl day` run `_autocap_stale_clocks`, auto-closing any open clock older than 12h (capped at 12h, reported) and warning on >6h with the `wl stop --at` correction. `wl active`'s "today's total" clips the live session to the local day so a pre-midnight start doesn't overcount today.
|
|
259
|
+
- **Log body rendering (oneline default vs `--log-format full`).** Every truncated view (`day` / `tree` / `logs` / this timeline) shows **one line per log**: `_truncate_log_body` collapses a multi-line body's newlines/whitespace runs to single spaces first, then truncates to the terminal width with `…` (budgeted against the real prefix width). A multi-line body must never spill continuation lines to column 0, nor let the width budget accumulate across lines. `--log-format full` keeps the whole body; where that body is multi-line the timeline hang-indents its continuation lines under the body column (like the `wl day` recap blockquote), not flush-left.
|
|
251
260
|
|
|
252
261
|
## 8. Time-window flags (unified, `_resolve_window`)
|
|
253
262
|
|
|
@@ -259,8 +268,10 @@ All "over a time range" commands (changes / summary, future `logs` etc.) share t
|
|
|
259
268
|
| `--until YYYY-MM-DD` | end (default: today) |
|
|
260
269
|
| `--week YYYY-Www` | ISO week (overrides since/until) |
|
|
261
270
|
| `--month YYYY-MM` | full month (overrides since/until) |
|
|
271
|
+
| `--quarter YYYY-Qn` | calendar quarter (overrides since/until) |
|
|
272
|
+
| `--year YYYY` | full year (overrides since/until) |
|
|
262
273
|
|
|
263
|
-
Priority: `week > month > since/until > current-Monday~today`. Single source `_resolve_window(args)` returns `(since, until)` as two `YYYY-MM-DD` strings.
|
|
274
|
+
Priority: `week > month > quarter > year > since/until > current-Monday~today`. Single source `_resolve_window(args)` returns `(since, until)` as two `YYYY-MM-DD` strings; a malformed period flag dies with a clear hint (validated via the `node_types` period oracle). When one grain is named, `wl summary` leads with that time node's goal as a dashboard header.
|
|
264
275
|
|
|
265
276
|
Range judgement is uniform: `since <= ts[:10] <= until` (date strings sort lexicographically = chronologically).
|
|
266
277
|
|
|
@@ -319,6 +330,7 @@ dev ai sync strategy reflection reading family health morning_check slack_scan
|
|
|
319
330
|
| `_has_tag(con, nid, tag)` | does the node have a given tag |
|
|
320
331
|
| `_ancestors_chain(con, nid)` | root → node ancestor chain |
|
|
321
332
|
| `_collect_descendants(con, rid)` | all descendant ids |
|
|
333
|
+
| `TERMINAL_STATUSES` | the `{DONE, CANCELED}` set — hidden-by-default / "settled" check |
|
|
322
334
|
|
|
323
335
|
Reuse these when a new command needs the functionality; do not write another copy.
|
|
324
336
|
|
|
@@ -529,6 +541,7 @@ When migrating a historical worklog, a log's `logged_at` must land on **the day
|
|
|
529
541
|
- Entry points all covered: `wl log --date` / import `logs` & `add_logs` / apply `+log` & `@log` — all go through `_insert_log`
|
|
530
542
|
- Illegal date is caught by `date.fromisoformat`
|
|
531
543
|
- `wl show` timeline sorts by `logged_at`, so historical dates land correctly
|
|
544
|
+
- **`logged_at` is ALWAYS a full instant** — `--date` without `--time` stamps the *current* wall-clock time on that date (`local_to_utc("<date> <now-HH:MM:SS>")`), so a backfilled log keeps intra-day ordering + a real `@HH:MM` instead of a bare date. Day-grouping still lands on the given date (a complete local datetime round-tripped through UTC, so timezone never rolls it). This is invariant #4 in `graph.py`, audited by `wl doctor`'s `bare_timestamp` check. (Older logs written before this — and the day-granularity `checkin` metric `at` — can still be bare dates; `local_day_sql`'s CASE handles those.)
|
|
532
545
|
|
|
533
546
|
## 22. Day reproduction view `wl day` + `wl logs --group` (markdown day structure)
|
|
534
547
|
|
|
@@ -554,6 +567,8 @@ The decision model: **schedule (forward planning, calendar-like) and log (retros
|
|
|
554
567
|
- `rrule`: recurrence rule, currently supports `daily` / `weekly:Mon,Wed` (`_norm_rrule` validates, weekdays use `Mon..Sun`). Complex RRULE (interval / specific day + time slot) is future work.
|
|
555
568
|
- **`_sched_fires(on_date, rrule, target)`**: does this row fire on `target`. `_scheduled_node_ids(con, target)` collects nodes hit on a given day.
|
|
556
569
|
- **`wl sched <id> [when] [--recur R] [--clear]`**: schedule / recur / clear / no-arg = list. `when` goes through `_resolve_concrete_date` (YYYY-MM-DD / today / tomorrow / day-after-tomorrow).
|
|
570
|
+
- **Stopping a recurrence — `wl sched stop <id> [date] [--rule R]`**: ends a recurrence at an **inclusive** end date (default today), keeping history. Encoded as a `;until=YYYY-MM-DD` suffix on the rrule string (`weekly:Wed;until=2026-06-30`), *not* a new column: `_split_until(rrule)` strips it, and `_sched_fires` returns False for `target > until` — so every rrule consumer (`_next_sched_fire`, `wl show`) stays until-aware for free by splitting first. Contrast with `--clear`/`rm`, which delete the row and erase the rule from *past* days too. A node with several rrules stops all unless `--rule` names one. `wl show` renders it `recur weekly:Wed (stopped 2026-06-30)`. `_rrule_active(rrule, today)` (no until, or until >= today) is the single "is this a live recurrence" test, shared by the `wl done` recurring-warning, `wl show`'s `last check-in:` line, and `wl ls --not-checked-in`.
|
|
571
|
+
- **Recurring check-in surfaces**: `wl show` shows a live recurring item's `last check-in: <date>` (or `never`), computed read-time from the check-in metrics (`_last_checkin`) — never a cached prop, so deleting a log can't leave a stale date. `wl ls --not-checked-in N` lists still-active recurring items not checked in within the last N days (or never) — the "what am I neglecting" view. Both suppress recurrences stopped in the past.
|
|
557
572
|
- **`wl day` derivation (`_node_plan(con, nid, sched_ids)`)**:
|
|
558
573
|
- Planned = `nid in sched_ids` (hit by schedule, scheduled in advance) **or** migration-period `planned` tag
|
|
559
574
|
- Unplanned = not in sched_ids and no `planned` tag (a logged-but-unscheduled task is unplanned; the former `unplanned` tag and the no-tag case are now one bucket)
|
|
@@ -632,6 +647,20 @@ Land the loader + `wl help` command + a handful of seed topics first; then autho
|
|
|
632
647
|
command / concept / param and trim each `--help` to summary + pointer. Topics land piecemeal —
|
|
633
648
|
a missing topic simply isn't offered; nothing breaks.
|
|
634
649
|
|
|
650
|
+
## 26. Data access layer (model classes + query decomposition)
|
|
651
|
+
|
|
652
|
+
The data layer is three tiers: `models.py` (one dataclass per table + CRUD) → handlers (`commands/`) → view DTOs (`commands/dtos.py`). `db_table.py` is the low-level SQL helper underneath the models (parameter binding, the `ALIVE` soft-delete predicate, identifier validation).
|
|
653
|
+
|
|
654
|
+
- **The soft-delete convention is lint-enforced, not review-enforced.** `ALIVE = "deleted_at IS NULL"` is the single source of the predicate; model reads AND the `_where`/`clause` helpers auto-AND it, but a raw JOIN/CTE/aggregate SELECT is the escape hatch that bypasses that — and forgetting it there leaks tombstoned rows into `wl day`/search. `tests/test_alive_lint.py` parses every source file with `ast` (f-strings collapsed to one statement) and asserts (1) `deleted_at IS NULL` is hand-written nowhere but the `ALIVE` definition, and (2) every raw read of a soft-deletable table carries ALIVE — inline or assembled in its enclosing function. The escape hatch stays open; the lint just makes forgetting it a red test instead of a silent leak. (Skips already-applied `migrations/` and the generated shell-completion templates.)
|
|
655
|
+
|
|
656
|
+
- **Model classes are Active-Record-lite.** One `@dataclass` per table mirrors its columns; **field names match column names exactly**. CRUD lives on shared bases so a concrete class is just `_table` + fields: `_Model` (query / query_one / count / exists / insert / delete / purge + `__getitem__`), the `_IdPK` mixin (get / gets / update for integer-id tables), the `_Upsertable` mixin (upsert for natural-key tables), with the table name in a `_table` class attr. `from_row` is one reflective builder (`cls(**{f.name: row[f.name] …})`) — a field with no matching column raises at construction, which *enforces* the field==column rule instead of leaving it a comment. Never add a display-only field to a model; that is what the `commands/dtos.py` view DTOs are for.
|
|
657
|
+
- **Reads always `SELECT *`** (from_row needs every column). A column projection or an aliased / JOIN / aggregate read goes straight to `db_table` — the deliberate escape hatch the full-row models can't express. Reads hide tombstones (`include_deleted=False` default); writes (`update` by id, `upsert`) see through them — an `upsert` revives a soft-deleted row. `_table` / `_upsert_key` live only on the non-dataclass mixins (untyped class attrs); an annotated `_foo: T` on a concrete dataclass would become a required field.
|
|
658
|
+
- **`gets(ids)` is the batch read** — a single query returning one slot per id (None for misses), preserving input order. Use it instead of an N+1 `get` loop.
|
|
659
|
+
- **Renderers take `Node`, not `sqlite3.Row`.** `_node_line` / `node_view` / `node_type` and the other node renderers receive `Node` objects. Every raw full-row node query converts at its boundary via `Node.from_row` (centralized inside `nodes_with_tag` for the tag path, so a new full-row caller can't forget the wrap), so a Row never reaches a renderer. The `_Model.__getitem__` shim lets a Node be subscripted by column name like a Row (declared columns only, KeyError on miss) — kept deliberately so the renderers' existing `n["title"]` indexing works without a mass rewrite to `n.title`. Dropping the shim (and migrating every `n["x"]` to attribute access) is a deferred cleanup: hundreds of accesses, no behavior change, negative ROI for now.
|
|
660
|
+
- **Query decomposition — prefer simple reads + Python compose over complex SQL.** The standing direction, consistent with G3 and with "no foreign keys" + "the time model derives membership in Python from the date, not a parent chain": decompose a complex read into simple single-table SELECTs composed in Python — each wrapped as a small function, reading **on-demand in batches** (only the ids actually needed, in one `id__in` query — never per-row, never the whole table) — rather than a JOIN / EXISTS-subquery / aggregate in one SQL statement. **Why:** the DB is small (low thousands of rows, ~2 MB), so a candidate read + Python compose is sub-millisecond and measures *faster* than the subquery SQL it replaces (the `workitem_sql` filter — six EXISTS subqueries — is 1.90 ms/call; reading the nodes and classifying in Python via the pure `node_type_from_props` is 1.59 ms, identical result set). Complex SQL is also the part hardest to maintain, profile, and refactor (by a human or an AI), so minimizing it is a maintainability win, not a perf cost. The `db_table` "NOT an ORM — complex reads stay explicit SQL" stance narrows accordingly: keep raw SQL only where decomposition genuinely can't help (e.g. GROUP BY vocab stats, the `local_day` time-window JOINs) or for projections. Simple single-table reads that were still hand-written SQL (`semantic.py` keyword LIKE, count(*)) have been aligned to `_db.query` / `Node.count` — `cmd_find` was already the template.
|
|
661
|
+
- **Type classification is on-demand batch, not EXISTS subqueries.** A node's type derives from its `type.*` props via the pure `node_type_from_props(props)` (precedence para > date > habit > meetlog > custom > task). The EXISTS-subquery SQL forms — `workitem_sql` (deleted), `nodes_with_type`, `time_node_by_period` — are replaced by one primitive: **`classify_types(con, node_ids)`** reads *just those ids'* `type.*` props in ONE batched query (`node_id__in`) and classifies in Python; `filter_workitems` (node batches) and `workitem_ids` (rows carrying a node_id) sit on top. The batch is the caller's actual need — a project's children, a day's logged nodes, a status-filtered set — read in one `id__in` query, not per-node (N+1). The read tracks the request, not a speculative full-table index built ahead of need; a whole-tree `summary` / orphans request *is* every live node, so that path does read all type props (the `id__in` list is unchunked — fine at the current low-thousands scale, a known ceiling against SQLite's variable limit if the tree ever grows huge). **No cache** — each call reads what it needs. An object-level identity-map cache that auto-expires on write (so attribute access goes through the cached object, consistent across a long-running process) is the agreed *next* step, deliberately deferred. The one render-loop N+1 still open is `_node_line`'s mixed type/clock/tags/planned per-node reads — a separate render-layer batch-prefetch, not yet done.
|
|
662
|
+
- **Graph operations are one module (`graph.py`).** Everything that walks an edge of the node graph lives here, the single source: tree (`parent_id` — `_ancestors_chain` up, `_collect_descendants` down), the `relation.*` association graph (`relation_view` / `_apply_relation` / `_backrels`), spoke-table cascade delete (`soft_delete_node` / `soft_delete_log`), structural membership (`_project_members`), and the `--by` grouping derivations (`_node_project` / `_sec_group` …). **Layering is one-way** (`queries ← graph ← commands`): `graph` imports the attribute/classification primitives from `queries` (`classify_types`, `node_type`, `_node_exists`, `_parse_id_list`, the `relation.*` constants) and NEVER the reverse, which is what keeps the import graph acyclic — if a graph op needs a new primitive, add it to `queries` and import it, don't reach sideways. An op belongs here **only if it traverses an edge**: a pure attribute filter (`make_node_filter` — filters one node by its tags/status/props, no walk) stays in `queries`; don't pull node-related-but-edgeless code in or this stops being "the graph" and becomes a junk drawer. Free functions, not a class — one table with cycle-safe walks already IS the repository layer (G3); `cli` re-exports the functions so tests reach them via the unified `wl.X` entry. The same module owns **`check_integrity` (`wl doctor`)** — the on-demand audit of the FK-less invariants (no dangling `parent_id`, no parent cycle, every live spoke + relation ref points at a live node, no self-reference); read-only, one batched read per table, O(N). (One-sided relations are NOT an invariant violation — every relation type is single-write by design.)
|
|
663
|
+
|
|
635
664
|
## 24+. Additional sections (24–37)
|
|
636
665
|
|
|
637
666
|
Sections 24 onward cover finer-grained extensions: metadata at the top of `wl day` (goal / summary / week & month goal blockquote), date context (`date_meta` for holidays / makeups), `--by-task` per-task aggregation in `wl logs`, `wl active` (active CLOCKs with elapsed), the `compound flag` semantics on `add` / `done` / `cancel` (`--log` / `--at` / `--link` / `--sched` / `--done` in one shot), `unlog` / `relog` (log editing through `#L<id>` references), time backfill (`start --at` / `stop --at` / `spent`), the multi-habit interactive `wl checkin`, recurrence rules including `-1` for "last day of period", the `wl ls` multi-dimension query model, default-tail-N to prevent screen-blast, shell completion via `print-completion` init-load (matching the starship / direnv / zoxide pattern), and the **battery-included command help design philosophy** (every `--help` carries a one-line intro + scenario + diff-from-neighbors).
|
|
@@ -50,7 +50,7 @@ worklog 刻意保持**少依赖、低抽象**:运行时只有 **stdlib + `rich
|
|
|
50
50
|
- **分类取值**(正交的 `type.*` props,没有单列):`type.para` = PARA 角色 `area / project / task`;`type.date` = 时间层级 `lifetime / decade / year / quarter / month / week / day`;`type.habit`;`type.meetlog`;自定义 `type.<x>`(可扩展,但加新 type 要想清楚它在 tree / projects / summary 里怎么归类)。一个 node 可同时持有多个;需要单个代表 token 时由 `node_type` / `node_type_from_props` 派生(优先级 para > date > habit > meetlog > custom > task)
|
|
51
51
|
- **status 只在 task / habit / meetlog 类用**;时间层级类(year/month/...)跟 project 类 status 留 NULL
|
|
52
52
|
- 表:`node / tag / log / metric / clock / prop / link / sched / date_meta` + 派生 view `v_node_path`
|
|
53
|
-
- **`node → log → metric` 主干**(log 为中心的核心):一个 `node` 挂多条 `log`;一条 `log`(带 `tag`——`note`/`goal`/`summary`/`metric`(载体)/…,NULL = 普通笔记)下挂 0..N 条 `metric`。`metric` 是结构化数据点(`tag` = 它是什么,如 `glucose`/`pullups`/`checkin`,或 `goal` = 一条 goal log 按优先级排序的目标 node id;外加 `value_num`/`value_text`/`unit`/`note`/`at`)。**`tag` 是三处统一的分类字段**——node(多值标签集)、log(角色,单值)、metric(种类,单值);同一个词、不同范围、SQL 不混。metric **必须挂在一条 log 上**(`metric.log_id` NOT NULL)——所以每个数据点都有 log 载体;`CHECK` 要求有值(纯标记如打卡存 `value_num=1`)。`metric.node_id` 是反范式冗余(免 join 查某 node 的数据点,无 FK;trigger 保证它始终等于载体 log 的 node)。CRUD 入口:`wl metric add/ls/edit/rm`(`add` 不带 `--on-log` 时建载体 log;无值标记存 `value_num=1`);`wl log`/`wl add` 的 `--metric` 和 `wl import` 的 `metrics`
|
|
53
|
+
- **`node → log → metric` 主干**(log 为中心的核心):一个 `node` 挂多条 `log`;一条 `log`(带 `tag`——`note`/`goal`/`summary`/`metric`(载体)/…,NULL = 普通笔记)下挂 0..N 条 `metric`。`metric` 是结构化数据点(`tag` = 它是什么,如 `glucose`/`pullups`/`checkin`,或 `goal` = 一条 goal log 按优先级排序的目标 node id;外加 `value_num`/`value_text`/`unit`/`note`/`at`)。**`tag` 是三处统一的分类字段**——node(多值标签集)、log(角色,单值)、metric(种类,单值);同一个词、不同范围、SQL 不混。metric **必须挂在一条 log 上**(`metric.log_id` NOT NULL)——所以每个数据点都有 log 载体;`CHECK` 要求有值(纯标记如打卡存 `value_num=1`)。`metric.node_id` 是反范式冗余(免 join 查某 node 的数据点,无 FK;trigger 保证它始终等于载体 log 的 node)。CRUD 入口:`wl metric add/ls/edit/rm`(`add` 不带 `--on-log` 时建载体 log;无值标记存 `value_num=1`);`wl log`/`wl add` 的 `--metric` 和 `wl import` 的 `metrics` 可内联挂数据点。循环项「今天做没做」= 当天有没有 `tag=checkin` 的 metric(`wl tick`/`wl checkin` 写),不再是「那天有没有 log」。`wl day` 对**任何循环项**当天有 checkin 的都渲染 `[x]`——习惯和 rrule 循环 task 一视同仁,完成标记跨循环类型统一。判据带循环门槛(`is_habit or _has_rrule`),所以一次性任务只是 `wl tick` 记个笔记(没 `--done`、仍 TODO)不会被误标完成。
|
|
54
54
|
- **保留 tag 的 log = 历史保留**:`goal`(前瞻,任意时间层级)和 `summary`(后顾日终)都是 `log.tag` 化的 log(最新一条=当前值,每次改追加一条),有自己的 `wl goal set/ls/rm <node>` 组(`--summary` 改写 summary),也由 bare `wl goal`/`wl recap`(今日自动)和 key-route 的 `wl set <node> goal|summary` 写。goal **每个层级同一个 `goal` tag**——node 的 type(day/week/month/year)就是层级;旧的 week `overview`、month `top5` 已并入 goal(迁移 0010)。一条 goal 可带结构化目标(写时显式给的 node id,顺序=优先级,存成 `goal` metric);`wl day`/`wl goal` 展示 goal + 编号带状态的目标列表 + `[done/total]`。(`prop` 回归只放真正静态的单值属性。)
|
|
55
55
|
- **`clock` = 结构化计时**:`clock(node_id, start_at, end_at, elapsed_sec)`,由 `wl start`/`stop`/`spent`/`wait` 读写——替代旧的 `CLOCK_IN`/`CLOCK_OUT` log-body 约定;时长从 `elapsed_sec` 求和,不再从文本解析。
|
|
56
56
|
- **两条并列树(都挂 lifetime 下)**(2026-05-29 起):
|
|
@@ -119,6 +119,7 @@ DONE / DEFERRED / CANCELED (已了结)
|
|
|
119
119
|
- `wl start` 写一条 body=`CLOCK_IN` 的 log;`wl stop` 写 `CLOCK_OUT elapsed=Nmin (from ...)` 的 log
|
|
120
120
|
- CLOCK_ 开头的 log 不算"进展 log"(changes / summary 里过滤掉)
|
|
121
121
|
- 工时统计 = 所有 CLOCK_OUT 的 `elapsed=Nmin` 累加(`_node_clock_min`)
|
|
122
|
+
- **open clock 生命周期。** 未关的 clock(`end_at NULL`)由 `wl stop` 关闭,现在每个终态/挂起状态——`wl done` / `cancel` / `wait` / `tick --done`——也经由单一的 `_close_open_clocks` helper 关闭,已关的任务绝不再计时。**忘关兜底:** open clock 在关闭前对任何当日总时长贡献为 0(`elapsed_sec` 一直是 NULL),所以忘 `wl start` 会静默丢掉整段时间;`wl active` 和 `wl day` 跑 `_autocap_stale_clocks`,自动关闭跑了超过 12h 的 open clock(封顶 12h、并提示),对超过 6h 的告警并给出 `wl stop --at` 修正命令。`wl active` 的「今日总计」把在跑的 session 裁剪到本地日,避免跨午夜多算。
|
|
122
123
|
|
|
123
124
|
## 8. 时间窗参数(统一,`_resolve_window`)
|
|
124
125
|
|
|
@@ -130,8 +131,10 @@ DONE / DEFERRED / CANCELED (已了结)
|
|
|
130
131
|
| `--until YYYY-MM-DD` | 止(默认今天)|
|
|
131
132
|
| `--week YYYY-Www` | ISO 周(覆盖 since/until)|
|
|
132
133
|
| `--month YYYY-MM` | 整月(覆盖 since/until)|
|
|
134
|
+
| `--quarter YYYY-Qn` | 自然季度(覆盖 since/until)|
|
|
135
|
+
| `--year YYYY` | 整年(覆盖 since/until)|
|
|
133
136
|
|
|
134
|
-
优先级:`week > month > since/until > 本周一~今天`。唯一来源 `_resolve_window(args)` → `(since, until)` 两个 `YYYY-MM-DD
|
|
137
|
+
优先级:`week > month > quarter > year > since/until > 本周一~今天`。唯一来源 `_resolve_window(args)` → `(since, until)` 两个 `YYYY-MM-DD`;格式非法的 period 参数经 `node_types` period oracle 校验后 die 报清楚提示。指定某一粒度时,`wl summary` 顶部展示该时间节点的 goal 作为 dashboard 头。
|
|
135
138
|
|
|
136
139
|
窗口判定统一:`since <= ts[:10] <= until`(日期字符串字典序 = 时间序)。
|
|
137
140
|
|
|
@@ -190,6 +193,7 @@ dev ai sync strategy reflection reading family health morning_check slack_scan
|
|
|
190
193
|
| `_has_tag(con, nid, tag)` | 节点是否有某 tag |
|
|
191
194
|
| `_ancestors_chain(con, nid)` | 根→节点祖先链 |
|
|
192
195
|
| `_collect_descendants(con, rid)` | 所有后代 id |
|
|
196
|
+
| `TERMINAL_STATUSES` | `{DONE, CANCELED}` 集合——默认隐藏/"了结"判断 |
|
|
193
197
|
|
|
194
198
|
新命令需要这些功能时复用,不要再写一份。
|
|
195
199
|
|
|
@@ -423,6 +427,8 @@ out(_c("✓", "done") + " " + _c(f"#{id}", "id") + " " + _c(title))
|
|
|
423
427
|
- `rrule`:重复规则,当前支持 `daily` / `weekly:Mon,Wed`(`_norm_rrule` 校验,星期用 Mon..Sun)。复杂 RRULE(间隔/某天+时段)后做。
|
|
424
428
|
- **`_sched_fires(on_date, rrule, target)`**:该行是否在 target 当天触发。`_scheduled_node_ids(con, target)` 收集当天被命中的 node。
|
|
425
429
|
- **`wl sched <id> [when] [--recur R] [--clear]`**:排期 / 重复 / 清除 / 无参列出。`when` 走 `_resolve_concrete_date`(YYYY-MM-DD / 今天 / 明天 / 后天)。
|
|
430
|
+
- **结束循环——`wl sched stop <id> [date] [--rule R]`**:在一个**含当天**的结束日(默认今天)停止循环,保留历史。以 rrule 字符串上的 `;until=YYYY-MM-DD` 后缀编码(`weekly:Wed;until=2026-06-30`),**不**加新列:`_split_until(rrule)` 剥离后缀,`_sched_fires` 对 `target > until` 返回 False——所以每个 rrule 消费点(`_next_sched_fire`、`wl show`)先剥离就天然 until 感知,改动面最小。对比 `--clear`/`rm` 会删掉整行、连**过去**的日子也抹掉。一个 node 挂多条 rrule 时默认全停,`--rule` 指定其一。`wl show` 渲染成 `recur weekly:Wed (stopped 2026-06-30)`。`_rrule_active(rrule, today)`(无 until,或 until >= today)是「这条循环是否还活着」的单一判据,`wl done` 循环警告、`wl show` 的 `last check-in:` 行、`wl ls --not-checked-in` 共用。
|
|
431
|
+
- **循环打卡查询面**:`wl show` 对活的循环项显示 `last check-in: <date>`(或 `never`),读时从 checkin metric 算(`_last_checkin`)——不落缓存 prop,删 log 也不会留脏日期。`wl ls --not-checked-in N` 列出仍活跃、最近 N 天(或从没)打过卡的循环项——「我在荒废什么」视图。两者都排除已停在过去的循环。
|
|
426
432
|
- **`wl day` 推导(`_node_plan(con, nid, sched_ids)`)**:
|
|
427
433
|
- 计划内 = `nid in sched_ids`(被 schedule 命中,事先排的)**或** 迁移期 `planned` tag。
|
|
428
434
|
- 计划外 = 没命中 sched 且无 `planned` tag(有 log 但没排期的就是计划外;原 `unplanned` tag 和"没打标"两种情况现在合并成一档)。
|
|
@@ -770,6 +776,20 @@ skill 里应该写什么:
|
|
|
770
776
|
|
|
771
777
|
skill 维护成本下降, help 一改全跟。
|
|
772
778
|
|
|
779
|
+
## 36. 数据访问层(model 类 + 查询拆解)
|
|
780
|
+
|
|
781
|
+
数据层三层: `models.py`(每表一个 dataclass + 增删改查)→ handlers(`commands/`)→ 视图 DTO(`commands/dtos.py`)。`db_table.py` 是 model 之下的底层 SQL 封装(参数绑定、`ALIVE` 软删除谓词、标识符校验)。
|
|
782
|
+
|
|
783
|
+
- **软删除约定靠 lint 强制,不靠 review。** `ALIVE = "deleted_at IS NULL"` 是谓词的单一来源;model 读取和 `_where`/`clause` 助手都会自动 AND 上它,但 raw JOIN/CTE/聚合 SELECT 是绕过它的逃生通道——在那儿漏掉就会把 tombstone 行泄漏进 `wl day`/搜索。`tests/test_alive_lint.py` 用 `ast` 解析每个源文件(f-string 折叠成一条语句),断言: (1) `deleted_at IS NULL` 除了 `ALIVE` 定义处别处不许手写; (2) 每个对软删表的 raw 读取都带 ALIVE——内联写或在所在函数里拼装(`where.append(_db.ALIVE)`)皆可。逃生通道照留, lint 只是把"漏掉"从静默泄漏变成红色测试。(跳过已应用的 `migrations/` 和生成的 shell 补全模板。)
|
|
784
|
+
|
|
785
|
+
- **model 类是 Active-Record-lite。** 每表一个 `@dataclass` 镜像表的列,**字段名与列名严格一致**。增删改查放在共享基类上,具体类只剩 `_table` + 字段: `_Model`(query / query_one / count / exists / insert / delete / purge + `__getitem__`)、`_IdPK` mixin(整数 id 表的 get / gets / update)、`_Upsertable` mixin(自然键表的 upsert),表名放在 `_table` 类属性里。`from_row` 是一个反射式构造器(`cls(**{f.name: row[f.name] …})`)——字段在行里找不到对应列就在构造时报错,把"字段名==列名"这条规则从注释变成**强制**。绝不给 model 加纯展示字段,那是 `commands/dtos.py` 视图 DTO 的职责。
|
|
786
|
+
- **读取一律 `SELECT *`**(from_row 需要每一列)。列投影、别名 / JOIN / 聚合读取直接走 `db_table`——这是全行 model 无法表达的、刻意保留的逃生通道。读取隐藏 tombstone(默认 `include_deleted=False`);写入(按 id 的 `update`、`upsert`)穿透它——`upsert` 会复活一个被软删的行。`_table` / `_upsert_key` 只声明在非 dataclass 的 mixin 上(无类型标注的类属性);在具体 dataclass 上写带标注的 `_foo: T` 会变成必填字段。
|
|
787
|
+
- **`gets(ids)` 是批量读取**——一次查询返回与输入等长、缺失为 None、保持顺序的列表。用它替代 N+1 的 `get` 循环。
|
|
788
|
+
- **渲染器接收 `Node`,不是 `sqlite3.Row`。** `_node_line` / `node_view` / `node_type` 等节点渲染器收到的是 `Node` 对象。每个 raw 全行 node 查询在边界用 `Node.from_row` 转换(tag 路径集中在 `nodes_with_tag` 内部转,新的全行调用点不会漏 wrap),所以 Row 永远到不了渲染器。`_Model.__getitem__` shim 让 Node 能像 Row 一样按列名下标访问(只认声明列,miss 抛 KeyError)——刻意保留,好让渲染器现有的 `n["title"]` 索引不必大改成 `n.title`。删掉 shim(把每个 `n["x"]` 迁成属性访问)是个推迟的清理: 几百处访问、零行为变化、当下 ROI 为负。
|
|
789
|
+
- **查询拆解——简单读 + Python 组合,优先于复杂 SQL。** 既定方向,和 G3、以及"不用外键" + "时间模型用 Python 从 date 推导归属、而非 parent 链"一脉相承: 把一个复杂读拆成若干简单单表 SELECT、在 Python 里组合——每个封装成小函数,**按需批量读取**(只取实际需要的那批 id,一次 `id__in` 查询——不逐行、不全表)——而不是把 JOIN / EXISTS 子查询 / 聚合塞进一条 SQL。**原因:** 库很小(几千行量级、~2 MB),候选读 + Python 组合是亚毫秒级,实测比它替代的子查询 SQL **更快**(`workitem_sql` 过滤——6 个 EXISTS 子查询——1.90 ms/次;读 node 再用纯函数 `node_type_from_props` 在 Python 里分类是 1.59 ms,结果集完全一致)。复杂 SQL 也是最难维护、最难做性能剖析、最难(被人或 AI)重构的部分,所以减少它是可维护性的净收益,不是性能代价。`db_table` 那句"NOT an ORM——复杂读保留显式 SQL"相应收窄: 只在拆解确实帮不上的地方(如 GROUP BY 词表统计、`local_day` 时间窗 JOIN)或投影场景保留 raw SQL。仍在手写 SQL 的简单单表读(`semantic.py` 的关键词 LIKE、count(*))已对齐到 `_db.query` / `Node.count`——`cmd_find` 本就是模板。
|
|
790
|
+
- **type 分类是按需批量,不是 EXISTS 子查询。** 节点的 type 由它的 `type.*` props 经纯函数 `node_type_from_props(props)` 推导(优先级 para > date > habit > meetlog > custom > task)。原来重新编码成 EXISTS 子查询的 SQL 形式(`workitem_sql`(已删)、`nodes_with_type`、`time_node_by_period`)已被一个原语取代: **`classify_types(con, node_ids)`** 只读这批 id 的 `type.*` props(一次 `node_id__in` 批量查询),在 Python 里分类;`filter_workitems`(过滤 node 批次)和 `workitem_ids`(过滤只带 node_id 的 JOIN 行)建在它上面。这批是调用方的实际需要——某 project 的子节点、某天有 log 的 node、status 过滤后的集合——一次 `id__in` 读出、不逐个(非 N+1)。读取跟着请求走,而不是预先建一个投机的全表索引;不过整树 `summary` / orphans 的请求*就是*全部存活 node,所以那条路径确实读了全部 type prop(`id__in` 列表未分块——当前几千行规模没问题,若整树涨到极大则是撞 SQLite 变量上限的已知天花板)。**不加缓存**——每次只读所需。对象级、写入时自动过期的 identity-map 缓存(让属性访问走缓存对象、长期运行进程内保持一致)是约定的*下一步*,刻意推迟。还没解决的渲染循环 N+1 只剩 `_node_line` 里 type/clock/tags/planned 混在一起的逐节点读取——那是单独的渲染层批量预取,尚未做。
|
|
791
|
+
- **图操作集中在一个模块(`graph.py`)。** 凡是沿 node 图的边遍历/变更的都在这里,作为 single source: 树(`parent_id`——`_ancestors_chain` 向上、`_collect_descendants` 向下)、`relation.*` 关系图(`relation_view` / `_apply_relation` / `_backrels`)、spoke 表级联软删(`soft_delete_node` / `soft_delete_log`)、结构成员(`_project_members`)、以及 `--by` 分组派生(`_node_project` / `_sec_group` …)。**单向依赖**(`queries ← graph ← commands`): `graph` 从 `queries` import 属性/分类原语(`classify_types`、`node_type`、`_node_exists`、`_parse_id_list`、`relation.*` 常量),**绝不反向**——这是保持 import 图无环的关键;图操作需要新原语就加到 `queries` 再 import,别横向伸手。一个操作进这里的标准是**必须沿边遍历**: 纯属性过滤(`make_node_filter`——按 tag/status/prop 过滤单个节点、不遍历)留在 `queries`;别把"和 node 有关但不沿边"的塞进来,否则这模块就不再是"图"而变成杂物抽屉。自由函数、不包类——一张表 + cycle-safe 遍历本身就是 repository 层(G3);`cli` re-export 这些函数,让测试经统一的 `wl.X` 入口访问。这个模块还拥有 **`check_integrity`(`wl doctor`)**——对无 FK 不变量的按需审计(无 dangling `parent_id`、无父链环、每个存活 spoke + relation 引用都指向存活节点、不自指);只读、每表一次批量读、O(N)。(relation 单向存一份是设计如此,不算不变量违反,不检测「单边」。)
|
|
792
|
+
|
|
773
793
|
## 引用
|
|
774
794
|
|
|
775
795
|
- `README.md` — 安装 + 命令速查 + schema
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
UV := uv
|
|
6
6
|
PYTHON := $(UV) run python
|
|
7
7
|
PYTEST := $(UV) run pytest
|
|
8
|
-
WL_BIN := $(HOME)/bin/wl
|
|
9
8
|
PROJ_DIR := $(shell pwd)
|
|
10
9
|
GIT := /usr/bin/git
|
|
11
10
|
|
|
@@ -49,29 +48,36 @@ docker-test: docker-test-image ## run the FULL suite inside the container (neve
|
|
|
49
48
|
docker run --rm -v "$(CURDIR)":/app -w /app $(DEVTEST_IMG)
|
|
50
49
|
|
|
51
50
|
# ── install / uninstall ──
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
@
|
|
51
|
+
# Safety rule: the daily `wl` must be a FROZEN snapshot from a CLEAN COMMITTED ref —
|
|
52
|
+
# never from the live working tree (.venv shares in-progress code + migrations).
|
|
53
|
+
# `make install` creates a temp git worktree at HEAD, runs `uv tool install` from it
|
|
54
|
+
# (isolated ~/.local/share/uv/tools/pyworklog/), then removes the worktree.
|
|
55
|
+
|
|
56
|
+
install: ## install wl from a clean committed HEAD (isolated from dev .venv)
|
|
57
|
+
@if ! $(GIT) diff --quiet || ! $(GIT) diff --cached --quiet; then \
|
|
58
|
+
echo "✗ working tree has uncommitted changes — commit first, then make install"; \
|
|
59
|
+
exit 1; \
|
|
60
|
+
fi
|
|
61
|
+
@WORKTREE=$$(mktemp -d /tmp/wl-install-XXXXXX) && \
|
|
62
|
+
$(GIT) worktree add --detach "$$WORKTREE" HEAD 2>/dev/null && \
|
|
63
|
+
$(UV) tool install --reinstall "$$WORKTREE" && \
|
|
64
|
+
$(GIT) worktree remove "$$WORKTREE"
|
|
65
|
+
@echo "✓ wl installed from clean HEAD (isolated snapshot, not dev .venv)"
|
|
59
66
|
@which wl
|
|
60
67
|
@echo ""
|
|
61
|
-
@echo "Shell completion
|
|
68
|
+
@echo "Shell completion:"
|
|
62
69
|
@echo " fish: add to ~/.config/fish/config.fish → wl print-completion fish | source"
|
|
63
70
|
@echo " bash: add to ~/.bashrc → eval \"\$$(wl print-completion bash)\""
|
|
64
71
|
@echo " zsh: add to ~/.zshrc → eval \"\$$(wl print-completion zsh)\""
|
|
65
72
|
@echo " aliases: ~/.config/worklog/aliases.ini [aliases] d = day / c = checkin / ..."
|
|
66
73
|
|
|
67
|
-
uninstall: ## remove
|
|
68
|
-
|
|
69
|
-
@echo "✓
|
|
70
|
-
@echo " (if ~/.config/fish/config.fish has 'wl print-completion fish | source', remove it manually)"
|
|
74
|
+
uninstall: ## remove the installed wl tool (keeps dev .venv + DB)
|
|
75
|
+
@$(UV) tool uninstall pyworklog 2>/dev/null || true
|
|
76
|
+
@echo "✓ pyworklog tool removed (dev .venv + DB $(WORKLOG_DB_PATH) kept)"
|
|
71
77
|
|
|
72
|
-
reinstall: uninstall install ## clean reinstall
|
|
78
|
+
reinstall: uninstall install ## clean reinstall from current committed HEAD
|
|
73
79
|
|
|
74
|
-
setup: sync install ## first-time setup:
|
|
80
|
+
setup: sync install ## first-time setup: sync dev deps + install isolated wl
|
|
75
81
|
|
|
76
82
|
# ── demo / sample data ──
|
|
77
83
|
|