Add TODO-1.6 + ROADMAP-2.0; retire MAINTENANCE.md and the codex tree
- TODO-1.6.md: skills-format migration of the eight slash commands (allowed-tools, disable-model-invocation, de-duped $ECHO block), plugin.json homepage, vault link repoints, and an open section for findings from 1.5.x live testing. - ROADMAP-2.0.md: packaging & structure major — repo artifact policy (Gitea releases, prune versioned zips), delete legacy commands/ dir, codex-as-build-target if ever needed, eval refresh to current metrics. Includes a disposition table absorbing every open MAINTENANCE.md item, so that checklist is removed. - Delete "codex plugin/": drifted derived tree; operator decision to regenerate from the canonical source after 2.0 if Codex use returns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,70 +0,0 @@
|
||||
# echo-memory — Maintenance & repo hygiene (M5)
|
||||
|
||||
Low-lift cleanups that remove "which copy is canonical?" traps before tagging 1.0.
|
||||
None of these change runtime behavior; they reduce drift and confusion.
|
||||
|
||||
## Canonical source tree
|
||||
|
||||
- **`echo-memory.plugin.src/`** is the single canonical source tree. ✅
|
||||
- **`codex plugin/`** has already drifted — it lacks the 0.9 modules
|
||||
(`echo_index.py`, `echo_links.py`, `echo_ops.py`, `sweep.py`, `check_routing.py`).
|
||||
- [ ] Decide: regenerate it from the canonical tree as a build target, **or** delete it
|
||||
and document the codex/CoWork packaging step instead.
|
||||
- [ ] Until resolved, add a `codex plugin/README.md` note: "derived — do not edit by hand."
|
||||
|
||||
## Build artifacts
|
||||
|
||||
The repo root carries every historical build (`echo-memory-0.6.0.plugin` …
|
||||
`echo-memory-0.9.0.plugin`) plus `echo-memory.plugin`.
|
||||
- [ ] Keep only the latest `echo-memory.plugin` (the installable) tracked; move versioned
|
||||
zips to GitHub Releases (or a `dist/` that is git-ignored).
|
||||
- [ ] Add `*.plugin` build outputs to `.gitignore` except the current pointer, if desired.
|
||||
- [x] `.gitignore` added (`.DS_Store`, `__pycache__`, local `.echo-memory/`, eval output).
|
||||
|
||||
## `plugin.json` completeness
|
||||
|
||||
- [x] Add `license` (UNLICENSED — personal, not for distribution).
|
||||
- [x] Version bumped to **1.0.0**.
|
||||
- [ ] Add `homepage` / repository URL (optional — repo URL not yet decided).
|
||||
- [ ] (Optional) register slash commands in the manifest; they're auto-discovered from
|
||||
`commands/` today (`echo-load|save|recall|triage|health|sweep|reflect|doctor`).
|
||||
|
||||
## Docs freshness
|
||||
|
||||
- [ ] `eval/run_eval.py` still benchmarks **0.6 vs 0.7** — refresh to current (open H4 follow-up).
|
||||
- [x] README version-history has a **1.0.0** entry; title bumped to v1.0.0.
|
||||
- [x] Scrub the literal bearer token from docs → `$ECHO_KEY` placeholder (M1, done:
|
||||
`api-reference.md` ×11, `SKILL.md`, `bootstrap.md`, eval harness).
|
||||
**Superseded in 1.3/1.4:** key resolution now lives in `echo_config.py`
|
||||
(machine-local `~/.claude/echo-memory/config.json`, `ECHO_*` env overrides, and
|
||||
empty-in-source `DEFAULT_*` constants filled only by `build.py --bake-key` for
|
||||
per-user artifacts in gitignored `dist/`). `API-KEY-SETUP.md` and the old
|
||||
`~/.echo-memory/credentials` fallback are gone; the committed tree ships zero
|
||||
credentials.
|
||||
|
||||
## Building the `.plugin` artifact
|
||||
|
||||
`python build.py` zips `echo-memory.plugin.src/` into `echo-memory-<version>.plugin` (version
|
||||
read from the manifest) and refreshes the `echo-memory.plugin` pointer. Deterministic
|
||||
(sorted entries + fixed timestamp → byte-identical rebuilds). Flags:
|
||||
|
||||
- `--strip-key` — force-blank the `echo_config.py` `DEFAULT_*` constants for a guaranteed
|
||||
token-free artifact (a clean source build is already token-free; this is the belt-and-braces flag).
|
||||
- `--bake-key --from <config.json> [--label <name>]` — per-user secret-bearing artifact in
|
||||
gitignored `dist/`; deliver directly to that one user, never commit (see README).
|
||||
- `--no-pointer` — skip refreshing `echo-memory.plugin`.
|
||||
- `--outdir DIR` — write artifacts elsewhere.
|
||||
|
||||
Without `--strip-key` the build warns that the baked-in token is present.
|
||||
|
||||
## Pre-1.0 release checklist
|
||||
|
||||
- [x] All 10 roadmap items wired; scaffold TODOs cleared (see `ROADMAP-1.0.md` status table).
|
||||
- [x] `schema_version` bumped to 4 (sweep/migrate); `migrate.py` `[3→4]` step added.
|
||||
*Verify on a copy of the live vault before migrating it (it currently reports schema 3).*
|
||||
- [x] Offline suites green in CI on Win/macOS/Linux × Py 3.10/3.12 (check_routing,
|
||||
test_echo_client, test_v1_scaffold, test_features, test_patch_semantics,
|
||||
test_offline_queue, test_reflect).
|
||||
- [ ] No live secret in the tracked tree or the built `.plugin` — **one line left**: delete
|
||||
`echo.py` `DEFAULT_KEY` after `ECHO_KEY`/credentials are set everywhere (per `API-KEY-SETUP.md`).
|
||||
- [ ] `eval/run_eval.py` reports current-version retrieval/durability metrics (open follow-up).
|
||||
@@ -0,0 +1,95 @@
|
||||
# echo-memory — Roadmap to v2.0.0
|
||||
|
||||
> Status: **draft.** Current shipping version: **1.5.1**. Interim work tracked in
|
||||
> `TODO-1.6.md`.
|
||||
>
|
||||
> **2.0 thesis: a packaging & structure major.** Nothing here changes what the plugin
|
||||
> *does* for memory — 2.0 is the release where the repo layout, artifact policy, and
|
||||
> plugin format are cleaned up in ways that are breaking for how the plugin is
|
||||
> *distributed and installed*, so the major version signals "reinstall everything,
|
||||
> re-clone expectations." Feature work keeps landing in 1.6.x until this is ready.
|
||||
>
|
||||
> This roadmap absorbs every open item from the retired `MAINTENANCE.md` (M5)
|
||||
> checklist; the mapping is noted per item.
|
||||
|
||||
---
|
||||
|
||||
## 1. Repo-root artifact policy (from MAINTENANCE › Build artifacts)
|
||||
|
||||
The repo root tracks every historical build (`echo-memory-0.6.0.plugin` …
|
||||
`echo-memory-1.5.1.plugin`) plus the `echo-memory.plugin` pointer. 2.0 changes the
|
||||
policy — **breaking for anyone who pulls artifacts straight from the repo**:
|
||||
|
||||
- [ ] Track only `echo-memory.plugin` (the current installable); delete the versioned
|
||||
zips from the tree.
|
||||
- [ ] Publish versioned builds as **Gitea releases** on `git.alwisp.com/jason/echo`
|
||||
instead (one release per tag, artifact attached).
|
||||
- [ ] `.gitignore`: `*.plugin` except the pointer.
|
||||
- [ ] Tag releases going forward (`v2.0.0`, …) so the release page is the version
|
||||
history for artifacts, and the README table stays the narrative history.
|
||||
|
||||
## 2. Complete the skills-format migration (finishes the 1.6 work)
|
||||
|
||||
1.6 migrates the eight slash commands to `skills/*/SKILL.md` with both formats
|
||||
coexisting (see `TODO-1.6.md`). 2.0 finishes it:
|
||||
|
||||
- [ ] **Delete the legacy `commands/` directory** — the packaging break that partly
|
||||
motivates the major bump.
|
||||
- [ ] Re-verify desktop + CoWork installs of the skills-only artifact.
|
||||
- [ ] Update SKILL.md / README / command docs that reference `commands/`.
|
||||
|
||||
## 3. Codex packaging (from MAINTENANCE › Canonical source tree)
|
||||
|
||||
The drifted `codex plugin/` tree was **deleted on 2026-07-03** (operator decision:
|
||||
Codex usage doesn't currently justify a maintained second build; it will be
|
||||
regenerated after 2.0 if needed). What remains for 2.0:
|
||||
|
||||
- [ ] If Codex support returns: implement it as a **build target** generated from the
|
||||
canonical `echo-memory.plugin.src/` tree (`build.py --target codex` or similar) —
|
||||
never as a second hand-maintained source tree (that's how the drift happened).
|
||||
- [ ] Otherwise: a short "packaging for other agent runtimes" note in the README
|
||||
documenting what a Codex/other-runtime port needs (manifest shape, skill entry
|
||||
point, script invocation).
|
||||
|
||||
## 4. Eval refresh — publish current-version metrics (from MAINTENANCE › Docs freshness; ROADMAP-1.0 H4 leftover)
|
||||
|
||||
`eval/run_eval.py` still benchmarks **0.6 vs 0.7**. The current-version suites
|
||||
(`test_features` et al.) prove correctness but publish no *metrics*.
|
||||
|
||||
- [ ] Refresh `run_eval.py` to measure the current version: retrieval precision/recall
|
||||
(BM25 + freshness/status fusion vs keyword baseline), duplicate rate with the
|
||||
gate on/off, offline-queue durability, silent-write-failure rate.
|
||||
- [ ] Publish the numbers in the README (replacing the 0.6-vs-0.7 claims) and archive
|
||||
the old A/B harness docs as historical.
|
||||
|
||||
## 5. Repo hygiene odds & ends
|
||||
|
||||
- [ ] Retire `ROADMAP-1.0.md` (fully shipped; keep in git history, drop from the tree)
|
||||
once this roadmap and the README version table carry the story.
|
||||
- [ ] Root README "Repository layout" section updated for the post-2.0 tree
|
||||
(no versioned zips, no codex tree, skills-only plugin).
|
||||
- [ ] Confirm `echo-improvements-prompt.md` (the 1.5.0 field report) either moves to a
|
||||
`docs/history/` folder or is deleted — its content is preserved in
|
||||
CHANGELOG 1.5.0 and the git history.
|
||||
|
||||
---
|
||||
|
||||
## MAINTENANCE.md disposition (why it was removed)
|
||||
|
||||
Every item from the retired checklist is either **done** or **absorbed above**:
|
||||
|
||||
| MAINTENANCE item | Status |
|
||||
|---|---|
|
||||
| Canonical tree = `echo-memory.plugin.src/` | Done (0.x) |
|
||||
| `codex plugin/` decide regenerate-or-delete | **Deleted 2026-07-03**; regenerate-as-build-target tracked in §3 |
|
||||
| Prune historical `.plugin` zips; gitignore policy | §1 |
|
||||
| `.gitignore` basics | Done (1.0) |
|
||||
| plugin.json `license` + version | Done (1.0) |
|
||||
| plugin.json `homepage` | `TODO-1.6.md` §2 (URL now decided) |
|
||||
| Register commands in manifest | Superseded by the skills migration (`TODO-1.6.md` §1, finished in §2 here) |
|
||||
| `run_eval.py` refresh to current metrics | §4 |
|
||||
| Secret handling (M1) | Done — superseded by `echo_config` in 1.3/1.4; committed tree ships zero credentials |
|
||||
| Pre-1.0 checklist | Done/obsolete (1.0 shipped; the `DEFAULT_KEY` line it referenced was removed in 1.3) |
|
||||
|
||||
Build flags (`--bake-key`, `--strip-key`, `--no-pointer`, `--outdir`) are documented
|
||||
in `build.py`'s docstring and the README Configuration section.
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# echo-memory — v1.6 TODO
|
||||
|
||||
> Working list for the next minor release. Started 2026-07-03, right after 1.5.1
|
||||
> shipped. **Add findings from 1.5.x live testing to the "Surfaced in testing"
|
||||
> section as they come up** — that's what this doc is for.
|
||||
|
||||
## 1. Migrate the eight slash commands to the skills format
|
||||
|
||||
The installer flags the plugin's `commands/` directory as the legacy format
|
||||
("Both formats work — consider migrating to skills/*/SKILL.md"). Confirmed against
|
||||
the official docs (code.claude.com/docs/en/skills.md, plugins.md):
|
||||
|
||||
- **Non-breaking.** `commands/echo-save.md` → `skills/echo-save/SKILL.md` keeps the
|
||||
invocation name: `/echo-save` works identically. No deprecation date on `commands/`;
|
||||
this is future-proofing, not a fire.
|
||||
- **Do it for the features, not the notice:**
|
||||
- [ ] `allowed-tools` per skill — pre-authorize the resolved `python3 "$ECHO" …`
|
||||
invocations so `/echo-load`, `/echo-health`, `/echo-recall` stop prompting
|
||||
(1.4.2 hand-broadened two commands; the skill format does this properly).
|
||||
- [ ] `disable-model-invocation: true` on the write-heavy entry points
|
||||
(`/echo-sweep`, `/echo-triage`) so only the operator triggers them; leave the
|
||||
read-side ones model-invocable.
|
||||
- [ ] De-duplicate the CoWork `$ECHO` path-resolution block currently copy-pasted
|
||||
into all eight command bodies — each skill folder can carry a shared snippet.
|
||||
- [ ] `argument-hint` carries over as-is; `$ARGUMENTS` substitution unchanged.
|
||||
- [ ] Verify a migrated build installs cleanly on desktop **and** in a CoWork session
|
||||
before deleting `commands/` (deleting the legacy dir is a 2.0 item —
|
||||
see `ROADMAP-2.0.md`; in 1.6 both may coexist).
|
||||
|
||||
## 2. plugin.json completeness (from the old MAINTENANCE checklist)
|
||||
|
||||
- [ ] Add `homepage` / repository URL — now decided: `https://git.alwisp.com/jason/echo`.
|
||||
|
||||
## 3. Vault follow-ups (noticed during the 1.5.0 dead-link cleanup)
|
||||
|
||||
Three of the 27 unwrapped "dead" links were actually **typos pointing at real notes**;
|
||||
they were unwrapped per instruction but repointing them would restore real graph edges:
|
||||
|
||||
- [ ] `projects/active/forgerunner.md` + `_agent/sessions/2026-06-29-2200-forgerunner-v010-ship.md`:
|
||||
"Gitea CI Docker jobs run on the host label" → the decision
|
||||
`decisions/by-date/2026-06-29-gitea-ci-docker-jobs-run-on-the-host-lab.md` ("lab", not "label").
|
||||
- [ ] `projects/active/mpm-brand-voice.md`: "Message Point Media (MPM)" ×3 → the company
|
||||
note `resources/companies/mpm.md` (or whatever is canonical for MPM).
|
||||
|
||||
## 4. Surfaced in 1.5.x testing
|
||||
|
||||
*(add items here as the new plugin gets real use)*
|
||||
|
||||
- [ ] —
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "echo-memory-local",
|
||||
"interface": {
|
||||
"displayName": "ECHO Memory Local"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "echo-memory",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/echo-memory"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "echo-memory",
|
||||
"version": "0.7.1-codex.0+codex.20260620053750",
|
||||
"description": "Persistent memory for Codex via the ECHO Obsidian vault over the Local REST API.",
|
||||
"author": {
|
||||
"name": "Jason"
|
||||
},
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "ECHO Memory",
|
||||
"shortDescription": "Use the ECHO Obsidian vault as Codex memory.",
|
||||
"longDescription": "Loads, writes, triages, and health-checks Jason's ECHO Obsidian vault using a status-checked Local REST API client and the plugin's bundled vault contract, routing map, templates, bootstrap, and migration logic.",
|
||||
"developerName": "Jason",
|
||||
"category": "Productivity",
|
||||
"capabilities": [
|
||||
"Memory",
|
||||
"Obsidian",
|
||||
"Automation"
|
||||
],
|
||||
"defaultPrompt": [
|
||||
"Load my ECHO memory.",
|
||||
"Save this to ECHO memory.",
|
||||
"Run ECHO vault health."
|
||||
],
|
||||
"brandColor": "#2563EB"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
# ECHO Memory Codex Plugin
|
||||
|
||||
Codex-native version of the ECHO memory plugin. It exposes one skill, `echo-memory`, with the vault contract, routing map, scaffold, and Python operational scripts needed to read, write, bootstrap, migrate, and health-check Jason's ECHO Obsidian vault.
|
||||
|
||||
## Runtime
|
||||
|
||||
Set `ECHO_KEY` before making live vault calls. The plugin does not ship a bearer token.
|
||||
|
||||
Optional:
|
||||
|
||||
- `ECHO_BASE`, default `https://echoapi.alwisp.com`
|
||||
- `ECHO_TODAY`, for deterministic date handling
|
||||
- `ECHO_VERIFY`, default `1`
|
||||
- `ECHO_LOCK_TTL`, default `900`
|
||||
|
||||
Primary scripts live in `skills/echo-memory/scripts/`:
|
||||
|
||||
- `echo.py`
|
||||
- `bootstrap.py`
|
||||
- `migrate.py`
|
||||
- `vault_lint.py`
|
||||
- `routing.json`
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
---
|
||||
name: echo-memory
|
||||
description: Use Jason's ECHO Obsidian vault as persistent memory in Codex. Use whenever Jason asks to remember, save, note, log, capture, recall, load profile/context, check prior notes, triage the ECHO inbox, run vault health, or pick up previous work. Also use proactively at the start of substantive work sessions to load context and at the end to log outcomes. Do not use for unrelated local-file lookup, email lookup, timed reminders, generic second-brain advice, memory meaning RAM, or another person's vault.
|
||||
---
|
||||
|
||||
# ECHO Memory
|
||||
|
||||
Use the **ECHO** Obsidian vault as Jason's persistent memory across Codex sessions. The vault is accessed through the Obsidian Local REST API at `https://echoapi.alwisp.com`.
|
||||
|
||||
This Codex port keeps the existing ECHO vault contract, routing map, scaffold, and safety rules, but the executable runtime is Python-first so it works in Codex on Windows without `/bin/bash`.
|
||||
|
||||
## Required Environment
|
||||
|
||||
Set `ECHO_KEY` in the environment before any live vault operation. The plugin intentionally does not ship a bearer token.
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
- `ECHO_BASE`: defaults to `https://echoapi.alwisp.com`
|
||||
- `ECHO_TODAY`: overrides today's date for deterministic writes and health checks
|
||||
- `ECHO_VERIFY`: defaults to `1`; read-back verifies `put`
|
||||
- `ECHO_LOCK_TTL`: defaults to `900` seconds
|
||||
|
||||
## Runtime
|
||||
|
||||
Prefer the bundled Python client for every vault read or write:
|
||||
|
||||
```bash
|
||||
python skills/echo-memory/scripts/echo.py get _agent/memory/semantic/operator-preferences.md
|
||||
python skills/echo-memory/scripts/echo.py append inbox/captures/inbox.md "- 2026-06-19: Jason wants this captured."
|
||||
python skills/echo-memory/scripts/echo.py patch _agent/context/current-context.md replace heading "Current Context::Scope" scope.md
|
||||
python skills/echo-memory/scripts/echo.py scope show
|
||||
python skills/echo-memory/scripts/echo.py scope set "Converting ECHO memory into a Codex plugin"
|
||||
```
|
||||
|
||||
The client centralizes auth injection, HTTP status checks, one bounded retry on transient 5xx/transport errors, idempotent append, read-back verification after `put`, frontmatter patching, advisory lock operations, and scope switching.
|
||||
|
||||
On Windows/PowerShell, prefer ASCII text in inline command arguments. For multiline markdown or text containing non-ASCII punctuation, write a UTF-8 temp file and pass the file path to `put` or `patch`; shell pipelines can down-convert characters before the client receives them.
|
||||
|
||||
Use these scripts for operational flows:
|
||||
|
||||
- `scripts/echo.py`: validated API client
|
||||
- `scripts/bootstrap.py`: idempotent vault setup/repair from bundled `scaffold/`
|
||||
- `scripts/migrate.py`: dry-run by default; use `--apply` for schema moves/deletes
|
||||
- `scripts/vault_lint.py`: read-only vault health invariant checker
|
||||
- `scripts/closeout.py`: writes the daily Agent Log entry, session log, and heartbeat pointer
|
||||
- `scripts/routing.json`: canonical machine-readable routing manifest
|
||||
|
||||
## Load Memory
|
||||
|
||||
At the start of any substantive work session, load memory. Issue the independent reads in parallel when practical:
|
||||
|
||||
1. `_agent/echo-vault.md`: bootstrap marker and schema version
|
||||
2. `_agent/memory/semantic/operator-preferences.md`: Jason profile/preferences
|
||||
3. `_agent/context/current-context.md`: active scope and scope history
|
||||
4. `_agent/heartbeat/last-session.md`, then the pointed session log if present
|
||||
5. `journal/daily/YYYY-MM-DD.md`: today's note; 404 is acceptable
|
||||
6. `inbox/captures/inbox.md`: inbox depth probe; 404 is acceptable
|
||||
|
||||
After loading, reconcile in one short line:
|
||||
|
||||
- If old inbox captures exist, surface the count and ask whether to triage.
|
||||
- If the recorded scope diverges from the current request, switch it before working with `scope set`.
|
||||
|
||||
Do not read the whole vault. Search targeted project or topic names when needed.
|
||||
|
||||
## Save Memory
|
||||
|
||||
Write when Jason asks to remember, save, note, log, capture, or when a durable fact, preference, commitment, decision, or session outcome should persist.
|
||||
|
||||
Rules:
|
||||
|
||||
- Route writes through `references/routing-map.md`; if no destination fits, capture to `inbox/captures/inbox.md`.
|
||||
- Search first before creating any slug-addressed note. Search both the slug and human title.
|
||||
- Read before append. `append` does this automatically and skips exact duplicate lines.
|
||||
- Preserve `created:` when merging. Bump `updated:` only for substantive changes.
|
||||
- Write in third person about Jason.
|
||||
- Never store secrets or API keys in a vault note.
|
||||
- Never put `[[wikilinks]]` in frontmatter; use the body `## Related` section.
|
||||
- Do not delete notes unless Jason explicitly asks and the destructive action is clearly safe.
|
||||
|
||||
After any substantive memory write, run the closeout workflow unless Jason explicitly says not to log it. This is separate from routing: ordinary writes update their canonical notes, and closeout records that the session happened.
|
||||
|
||||
```bash
|
||||
python skills/echo-memory/scripts/closeout.py \
|
||||
--slug "brief-session-topic" \
|
||||
--goal "What this session was for." \
|
||||
--actions "What was changed or recorded." \
|
||||
--next-step "What to do first next time." \
|
||||
--daily-line "One-line Agent Log entry." \
|
||||
--related "[[path/to/relevant-note]]"
|
||||
```
|
||||
|
||||
The helper ensures `journal/daily/YYYY-MM-DD.md`, appends under `## Agent Log`, writes `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md`, and updates `_agent/heartbeat/last-session.md`.
|
||||
|
||||
## Triage Inbox
|
||||
|
||||
For "triage ECHO inbox", read `inbox/captures/inbox.md`, identify dated captures older than about 7 days that have not been routed, and offer to route them. For accepted items, write to the canonical destination and append an audit line to `inbox/processing-log/YYYY-MM-DD.md`. Do not delete the original capture unless Jason explicitly asks.
|
||||
|
||||
## Vault Health
|
||||
|
||||
For "ECHO health" or monthly maintenance, run:
|
||||
|
||||
```bash
|
||||
ECHO_TODAY=<current-date> python skills/echo-memory/scripts/vault_lint.py
|
||||
```
|
||||
|
||||
Exit codes:
|
||||
|
||||
- `0`: clean
|
||||
- `1`: violations found
|
||||
- `2`: vault unreachable
|
||||
- `3`: not bootstrapped
|
||||
|
||||
Summarize findings by category. Do not auto-fix without Jason's go-ahead.
|
||||
|
||||
## Bootstrap And Migrate
|
||||
|
||||
If `_agent/echo-vault.md` is missing, run a dry run first:
|
||||
|
||||
```bash
|
||||
python skills/echo-memory/scripts/bootstrap.py --dry-run
|
||||
```
|
||||
|
||||
Then run without `--dry-run` only if Jason wants the vault bootstrapped or repaired. The bootstrap is additive and probe-before-write.
|
||||
|
||||
If the marker schema is older than this plugin, run:
|
||||
|
||||
```bash
|
||||
python skills/echo-memory/scripts/migrate.py
|
||||
```
|
||||
|
||||
That prints a dry-run plan. Use `--apply` only with explicit approval for moves/deletes.
|
||||
|
||||
## References
|
||||
|
||||
- Durable operating contract: `references/operating-contract.md`
|
||||
- Bootstrap/repair/migration details: `references/bootstrap.md`
|
||||
- Vault layout and frontmatter: `references/vault-layout.md`
|
||||
- Canonical routing map: `references/routing-map.md`
|
||||
- REST API reference: `references/api-reference.md`
|
||||
- Session log template: `references/session-log-template.md`
|
||||
@@ -1,216 +0,0 @@
|
||||
# ECHO — Obsidian Local REST API Reference
|
||||
|
||||
Server: `https://echoapi.alwisp.com` (reverse proxy → backend Obsidian Local REST API)
|
||||
Auth header: `Authorization: Bearer <set ECHO_KEY in the environment>`
|
||||
The endpoint has a **valid TLS certificate** — `-k` is not required. Paths address the vault at its **root**.
|
||||
|
||||
> **Prefer `scripts/echo.py` over the raw recipes below.** It wraps every verb with auth, status checking, retry, idempotent append, and frontmatter patches. The recipes here are the underlying mechanics and the fallback. **If you call `curl` directly, check the HTTP status** — add `-o /dev/null -w "%{http_code}"` and branch on it. A `PATCH` to a non-existent heading returns `400 invalid-target` (errorCode 40080) and the write is *silently lost*; a bare `curl` that ignores status will report success anyway. `GET` returns `404` for a missing file. Treat any `>= 400` as a failed operation, surface it, and do not continue as if it succeeded.
|
||||
|
||||
---
|
||||
|
||||
## Reading Files
|
||||
|
||||
```bash
|
||||
# Read any file by vault path
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/context/current-context.md"
|
||||
```
|
||||
|
||||
Returns raw file content (text/markdown). On 404, the file does not exist.
|
||||
|
||||
```bash
|
||||
# Read a specific heading's content only
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md/heading/Operator"
|
||||
```
|
||||
|
||||
Nested headings: separate levels with `::` (URL-encode spaces as `%20`):
|
||||
```
|
||||
/vault/path/to/note.md/heading/Work%3A%3AMeetings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Listing Directories
|
||||
|
||||
```bash
|
||||
# List contents of a directory (trailing slash required)
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/sessions/"
|
||||
```
|
||||
|
||||
Returns JSON: `{ "files": [...], "folders": [...] }`.
|
||||
|
||||
---
|
||||
|
||||
## Appending Content (POST)
|
||||
|
||||
`POST` appends to the **end** of an existing file. Creates the file if it doesn't exist.
|
||||
|
||||
```bash
|
||||
cat > /tmp/obs_entry.md << 'OBSEOF'
|
||||
- 2026-06-05: your entry here
|
||||
OBSEOF
|
||||
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
|
||||
-H "Content-Type: text/markdown" \
|
||||
--data-binary @/tmp/obs_entry.md \
|
||||
"https://echoapi.alwisp.com/vault/inbox/captures/inbox.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating or Overwriting Files (PUT)
|
||||
|
||||
`PUT` creates a new file or fully overwrites an existing one. Intermediate directories are created automatically.
|
||||
|
||||
```bash
|
||||
cat > /tmp/obs_file.md << 'OBSEOF'
|
||||
---
|
||||
type: session-log
|
||||
status: complete
|
||||
created: 2026-06-05
|
||||
updated: 2026-06-05
|
||||
tags: [agent, session]
|
||||
agent_written: true
|
||||
source_notes: []
|
||||
---
|
||||
|
||||
# content here
|
||||
|
||||
## Related
|
||||
- [[projects/active/some-project]]
|
||||
OBSEOF
|
||||
|
||||
curl -s -X PUT \
|
||||
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
|
||||
-H "Content-Type: text/markdown" \
|
||||
--data-binary @/tmp/obs_file.md \
|
||||
"https://echoapi.alwisp.com/vault/_agent/sessions/2026-06-05-1430-my-session.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Patching a Specific Section (PATCH)
|
||||
|
||||
`PATCH` edits inside a file without rewriting it. Target and operation are set via headers.
|
||||
|
||||
### Append under a heading
|
||||
|
||||
**Heading targets must be the FULL heading path, `::`-delimited from the top-level heading.** A bare subheading name returns `400 invalid-target` (errorCode 40080). Example: `## Fact / Pattern` nested under `# Operator Preferences` → `Target: Operator Preferences::Fact / Pattern`. Percent-encode non-ASCII characters (e.g. `H%C3%A9llo`); spaces are fine.
|
||||
|
||||
```bash
|
||||
cat > /tmp/obs_patch.md << 'OBSEOF'
|
||||
Jason prefers concise status updates — lead with the decision.
|
||||
OBSEOF
|
||||
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
|
||||
-H "Operation: append" \
|
||||
-H "Target-Type: heading" \
|
||||
-H "Target: Operator Preferences::Fact / Pattern" \
|
||||
-H "Content-Type: text/markdown" \
|
||||
--data-binary @/tmp/obs_patch.md \
|
||||
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
```
|
||||
|
||||
### Discover heading / block / frontmatter targets
|
||||
|
||||
When unsure of the exact heading path, GET the note with the document-map Accept header:
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
|
||||
-H "Accept: application/vnd.olrapi.document-map+json" \
|
||||
"https://echoapi.alwisp.com/vault/_agent/memory/semantic/operator-preferences.md"
|
||||
```
|
||||
|
||||
Returns `{ "headings": [...], "blocks": [...], "frontmatterFields": [...] }`. Copy the heading string verbatim into `Target`.
|
||||
|
||||
### Replace a heading's content entirely
|
||||
|
||||
Same call with `Operation: replace` — e.g. to refresh a project's `Project Name::Status`.
|
||||
|
||||
### Prepend under a heading
|
||||
|
||||
Same call with `Operation: prepend`.
|
||||
|
||||
### Patch a frontmatter field
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
|
||||
-H "Operation: replace" \
|
||||
-H "Target-Type: frontmatter" \
|
||||
-H "Target: updated" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '"2026-06-05"' \
|
||||
"https://echoapi.alwisp.com/vault/projects/active/vault-foundation.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Searching
|
||||
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
|
||||
"https://echoapi.alwisp.com/search/simple/?query=weekly+review"
|
||||
```
|
||||
|
||||
Returns an array of `{ filename, score, matches: [{ context, match }] }`.
|
||||
|
||||
---
|
||||
|
||||
## Deleting Files
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Bearer <set ECHO_KEY in the environment>" \
|
||||
"https://echoapi.alwisp.com/vault/inbox/imports/old-note.md"
|
||||
```
|
||||
|
||||
Only on explicit operator request. Deletion is destructive.
|
||||
|
||||
---
|
||||
|
||||
## URL-Encoding Notes
|
||||
|
||||
- Path separators (`/`) in the vault path are **literal** — do not encode them.
|
||||
- Spaces in filenames or heading targets in a URL: use `%20`.
|
||||
- Nested heading levels in a URL path: use `%3A%3A` for `::`.
|
||||
- Heading text in the `Target:` header: the **full heading path** joined by `::` (e.g. `Operator Preferences::Fact / Pattern`), no `#`; spaces are fine; percent-encode non-ASCII.
|
||||
|
||||
---
|
||||
|
||||
## Memory Routing Map
|
||||
|
||||
| Situation | Vault path | Method |
|
||||
|-----------|-----------|--------|
|
||||
| Quick capture / unsorted | `inbox/captures/inbox.md` (date-prefixed line) | POST |
|
||||
| Raw imported material | `inbox/imports/` | PUT |
|
||||
| Operator preference / durable fact | `_agent/memory/semantic/operator-preferences.md` | PATCH |
|
||||
| Other durable facts, patterns | `_agent/memory/semantic/<slug>.md` | PUT |
|
||||
| Event record (what happened) | `_agent/memory/episodic/<slug>.md` | PUT |
|
||||
| Short-lived, time-boxed state | `_agent/memory/working/<slug>.md` | PUT |
|
||||
| Task-scoped context / focus | `_agent/context/current-context.md` | PATCH / PUT |
|
||||
| Working-session log | `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md` | PUT |
|
||||
| Long-running project state | `projects/<lifecycle>/<slug>.md` (lifecycle: `incubating` → `active` → `on-hold`/`archived`; folder and `status:` MUST agree) | PUT + PATCH |
|
||||
| Ongoing area of responsibility (standing domain, no end state) | `areas/<domain>/<slug>.md` (`<domain>`: `business`/`personal`/`learning`/`systems`) | PUT |
|
||||
| Non-obvious decision (ADR) | `decisions/by-date/YYYY-MM-DD-<slug>.md` (mirror only into an existing project note's `## Key Decisions`; otherwise skip) | PUT |
|
||||
| Person context | `resources/people/<name>.md` | PUT / PATCH |
|
||||
| Company / organization context | `resources/companies/<slug>.md` | PUT / PATCH |
|
||||
| Concept / reference note | `resources/concepts/` or `resources/references/` | PUT |
|
||||
| Meeting notes / call recap | `resources/meetings/YYYY-MM-DD-<slug>.md` | PUT |
|
||||
| Skill / plugin capability entry (catalog, not build work) | `_agent/skills/active/<slug>.md` (→ `archived/` when retired) | PUT |
|
||||
| Daily activity / Agent Log | `journal/daily/YYYY-MM-DD.md` | POST / PATCH |
|
||||
| Journal rollup | `journal/{weekly/YYYY-Www,monthly/YYYY-MM,quarterly/YYYY-Qn,annual/YYYY}.md` (weekly = opt-in on first session of a new ISO week; monthly = offered with Vault Health; quarterly/annual = manual) | PUT |
|
||||
| Vault-health audit (agent self-maintenance) | `_agent/health/YYYY-MM-vault-health.md` (monthly; NOT a journal entry) | PUT |
|
||||
| Session-end orientation pointer | `_agent/heartbeat/last-session.md` (one line, overwritten each session end) | PUT |
|
||||
| Bootstrap marker (plugin-owned) | `_agent/echo-vault.md` (`schema_version`, bootstrap date) — the "is this vault set up?" probe | GET / PUT |
|
||||
|
||||
**Slug rules:** kebab-case, ASCII, ~40 chars max. Every file carries canonical frontmatter (see `vault-layout.md`).
|
||||
@@ -1,140 +0,0 @@
|
||||
# Bootstrap & Repair
|
||||
|
||||
The **plugin is the single source of truth** for ECHO's structure. Everything needed to stand up a vault ships in this skill under `scaffold/` — there is no dependency on any in-vault control doc and no external/local re-seed path. This makes the vault portable: point the REST API at any empty Obsidian vault, run this procedure, and it becomes a working ECHO vault.
|
||||
|
||||
The vault holds **data only**. It carries no `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` / `index.md`. The "is this vault set up?" signal is a small marker file, `_agent/echo-vault.md`.
|
||||
|
||||
## Quick path — run the scripts
|
||||
|
||||
Bootstrap, repair, and migration are deterministic scripts; prefer them over running the curl steps by hand. They resolve the scaffold relative to their own location, so they work regardless of the caller's CWD:
|
||||
|
||||
```bash
|
||||
SCRIPTS="the scripts directory beside this skill"
|
||||
python "$SCRIPTS/bootstrap.py" --dry-run # preview what would be seeded
|
||||
python "$SCRIPTS/bootstrap.py" # idempotent, additive — fills only what is missing (also the repair path)
|
||||
python "$SCRIPTS/migrate.py" # plan a schema migration (dry-run)
|
||||
python "$SCRIPTS/migrate.py" --apply # perform the migration (moves/deletes, after review)
|
||||
```
|
||||
|
||||
`bootstrap.py` writes through `echo.py`, so every step is status-checked and the marker is written last. The manual steps below document what the script does (and serve as a fallback if the script can't run).
|
||||
|
||||
```
|
||||
AUTH="Authorization: Bearer <set ECHO_KEY in the environment>"
|
||||
BASE="https://echoapi.alwisp.com"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Probe — is the vault bootstrapped?
|
||||
|
||||
At session start, GET the marker:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" -H "$AUTH" "$BASE/vault/_agent/echo-vault.md"
|
||||
```
|
||||
|
||||
- **200** → bootstrapped. Read the marker's `schema_version`; if it is **less than** the plugin's current schema (2), run a migration pass (see *Migrations* below), otherwise proceed straight to the loading procedure in `SKILL.md`.
|
||||
- **404** → empty/unconfigured vault. Run **Fresh bootstrap** below. (If you expected an existing vault, confirm with the operator once that the REST API is pointed at the right vault before seeding.)
|
||||
|
||||
---
|
||||
|
||||
## Fresh bootstrap (empty vault)
|
||||
|
||||
Idempotent and additive. **Never overwrite an existing file** — GET-probe each path and skip any that already returns `200`. The marker is written **last**, so the vault is only flagged "set up" after every piece is in place.
|
||||
|
||||
Throughout, `{{DATE}}` means today's date (`YYYY-MM-DD`), resolved from the conversation's `currentDate`. Substitute it before PUTting any scaffold file or anchor seed.
|
||||
|
||||
### 1. Folder tree
|
||||
|
||||
The REST API auto-creates parent directories on PUT, so creating the tree = seeding a file into each leaf. Obsidian and git both ignore empty dirs, so drop a one-line `README.md` into any leaf that wouldn't otherwise receive a file. Required tree (leaves marked `→ README`):
|
||||
|
||||
```
|
||||
inbox/captures inbox/imports inbox/processing-log
|
||||
journal/daily journal/weekly journal/monthly journal/quarterly journal/annual journal/templates
|
||||
projects/active projects/incubating projects/on-hold projects/archived
|
||||
areas/business areas/personal areas/learning areas/systems
|
||||
resources/concepts resources/references resources/people resources/companies resources/meetings
|
||||
decisions/by-date
|
||||
_agent/context _agent/memory/working _agent/memory/episodic _agent/memory/semantic
|
||||
_agent/sessions _agent/health _agent/templates _agent/heartbeat
|
||||
_agent/skills/active _agent/skills/archived
|
||||
```
|
||||
|
||||
> `decisions/by-project/` is intentionally **not** created — it is retired. A project-relevant decision is mirrored as a `[[wikilink]]` under that project's `## Key Decisions` heading instead.
|
||||
>
|
||||
> `reviews/` is **not** created — it is retired. Journal rollups (weekly/monthly/quarterly/annual) live under `journal/`; the monthly vault-health audit lives under `_agent/health/`.
|
||||
|
||||
A leaf README is just a one-liner, e.g.:
|
||||
|
||||
```bash
|
||||
printf '# %s\n\nMemory vault folder. See the echo-memory plugin for conventions.\n' "captures" \
|
||||
| curl -s -X PUT -H "$AUTH" -H "Content-Type: text/markdown" --data-binary @- \
|
||||
"$BASE/vault/inbox/captures/README.md"
|
||||
```
|
||||
|
||||
### 2. Templates
|
||||
|
||||
PUT every file under this skill's `scaffold/templates/` to its mirrored vault path. The tree mirrors 1:1:
|
||||
|
||||
| Scaffold file | Vault path |
|
||||
|---|---|
|
||||
| `scaffold/templates/_agent/templates/session-log-template.md` | `_agent/templates/session-log-template.md` |
|
||||
| `scaffold/templates/_agent/templates/context-bundle-template.md` | `_agent/templates/context-bundle-template.md` |
|
||||
| `scaffold/templates/_agent/templates/working-memory-template.md` | `_agent/templates/working-memory-template.md` |
|
||||
| `scaffold/templates/_agent/templates/semantic-memory-template.md` | `_agent/templates/semantic-memory-template.md` |
|
||||
| `scaffold/templates/journal/templates/daily-note-template.md` | `journal/templates/daily-note-template.md` |
|
||||
| `scaffold/templates/journal/templates/weekly-review-template.md` | `journal/templates/weekly-review-template.md` |
|
||||
| `scaffold/templates/projects/project-template.md` | `projects/project-template.md` |
|
||||
| `scaffold/templates/decisions/decision-template.md` | `decisions/decision-template.md` |
|
||||
|
||||
Templates keep their Obsidian Templater tokens (`{{date:YYYY-MM-DD}}` etc.) verbatim — those are resolved by Templater / by the skill's daily-note routine, not at seed time.
|
||||
|
||||
Resolve scaffold paths against the skill directory — **never a bare relative `@scaffold/...`**, which assumes the caller's CWD is the skill dir and silently sends an empty body otherwise:
|
||||
|
||||
```bash
|
||||
SCAFFOLD="the scaffold directory beside this skill"
|
||||
curl -s -X PUT -H "$AUTH" -H "Content-Type: text/markdown" \
|
||||
--data-binary @"$SCAFFOLD/templates/journal/templates/daily-note-template.md" \
|
||||
"$BASE/vault/journal/templates/daily-note-template.md"
|
||||
```
|
||||
|
||||
### 3. Anchor seeds (only if absent — no fabricated facts)
|
||||
|
||||
Substitute `{{DATE}}` → today, then PUT each:
|
||||
|
||||
| Scaffold file | Vault path |
|
||||
|---|---|
|
||||
| `scaffold/anchors/operator-preferences.seed.md` | `_agent/memory/semantic/operator-preferences.md` |
|
||||
| `scaffold/anchors/current-context.seed.md` | `_agent/context/current-context.md` |
|
||||
| `scaffold/anchors/inbox.seed.md` | `inbox/captures/inbox.md` |
|
||||
|
||||
The operator-preferences seed is deliberately empty — **do not invent preferences.** Real facts accrue through use.
|
||||
|
||||
### 4. Vault README (human signpost)
|
||||
|
||||
Substitute `{{DATE}}` if present, then PUT `scaffold/README.vault.md` → `/vault/README.md`. This is the only human-facing control doc in the vault and is **not** read by the agent for routing.
|
||||
|
||||
### 5. Marker (write last)
|
||||
|
||||
Substitute `{{DATE}}`, then PUT `scaffold/echo-vault.md` → `/vault/_agent/echo-vault.md`. Once this returns `200`, the vault is bootstrapped.
|
||||
|
||||
### 6. First-run trace
|
||||
|
||||
Create today's daily note from `journal/templates/daily-note-template.md` (resolve the `{{date:…}}` tokens to today), append a one-line `## Agent Log` entry noting the bootstrap, and write a session log in `_agent/sessions/YYYY-MM-DD-HHMM-bootstrap.md`. Tell the operator briefly what was created.
|
||||
|
||||
---
|
||||
|
||||
## Repair (existing vault, something missing)
|
||||
|
||||
Run the same steps 1–5, but GET-probe each path first and **only create what is missing**. Never overwrite. If a file exists but looks malformed, flag it in the session log rather than replacing it. Repair is safe to run any time and is the right response if the loading procedure hits an unexpected `404` on a structural path.
|
||||
|
||||
---
|
||||
|
||||
## Migrations (`schema_version` mismatch)
|
||||
|
||||
`scripts/migrate.py` automates this: it reads the marker's `schema_version`, applies each intervening migration (idempotent, additive; destructive steps gated behind `--apply` and printed first), and stamps the marker at the end. Run it dry-run, review the plan, then `--apply`. The steps below are what it encodes — and the manual fallback.
|
||||
|
||||
When the marker's `schema_version` is older than the plugin's, apply the migration steps for each intervening version, then PATCH the marker's `schema_version` frontmatter to the new value.
|
||||
|
||||
- **0 → 1** (control-docs-in-plugin): the vault previously carried root control docs (`CLAUDE.md`, `BOOTSTRAP.md`, `STRUCTURE.md`, `index.md`). Back them up outside the vault, DELETE them, PUT the thin `scaffold/README.vault.md` over the old verbose `README.md`, write the `_agent/echo-vault.md` marker, and scrub now-dangling `[[CLAUDE]]`/`[[BOOTSTRAP]]`/`[[STRUCTURE]]`/`[[index]]` links from the `## Related` sections of `operator-preferences.md` and `current-context.md` (leave historical session logs alone). Confirm with the operator before deleting.
|
||||
- **1 → 2** (reviews-folded-into-journal): the `reviews/` tree is retired. (a) For each note under `reviews/weekly/` and `reviews/monthly/`, MOVE it into `journal/weekly/` (rename `YYYY-Www-review.md` → `YYYY-Www.md`) and `journal/monthly/` respectively, preserving the earliest `created:`. (b) Move any `reviews/monthly/YYYY-MM-vault-health.md` to `_agent/health/`. (c) Move `reviews/quarterly|annual/` artifacts to `journal/quarterly|annual/`. (d) Update inbound `[[reviews/...]]` wikilinks in `## Related` sections to the new paths. (e) DELETE the now-empty `reviews/` tree. Confirm with the operator before deleting; leave historical session logs alone. *(Jason's live vault was hand-migrated for the one existing `2026-W24` artifact on 2026-06-10; this step covers any vault bootstrapped under schema 1.)*
|
||||
@@ -1,45 +0,0 @@
|
||||
# ECHO — Operating Contract
|
||||
|
||||
The durable, client-independent contract for any agent operating against the ECHO vault. These principles and safety rules formerly lived in the vault's `CLAUDE.md`; they now live in the plugin so they survive regardless of what is (or isn't) in the vault. Day-to-day *procedure* — loading order, search-first, triage, scope switching, PATCH/append rules — is owned by `SKILL.md`. This file holds the things that don't change between sessions or clients.
|
||||
|
||||
## What this agent is
|
||||
|
||||
You are an agent operating against an Obsidian vault that functions as a shared, long-term memory substrate for human work, Codex / CoWork sessions, and future REST/MCP clients. Your role is to read context, synthesize across notes, produce structured outputs, update memory carefully, and leave durable traces in the vault rather than keeping important state only in the conversation. The vault is the **system of record**, not a scratchpad.
|
||||
|
||||
## Core principles
|
||||
|
||||
- Treat the vault as the system of record for long-term memory.
|
||||
- Prefer Markdown, YAML frontmatter, wiki-links, and stable folder conventions over proprietary structures.
|
||||
- Write notes so both humans and agents can understand them without hidden context.
|
||||
- Preserve history instead of silently overwriting important decisions, summaries, or inferred preferences.
|
||||
- Keep agent-managed content (`agent_written: true` + `source_notes`) clearly separated from human-authored content.
|
||||
- Default to **additive updates, explicit status changes, and traceable summaries.**
|
||||
- Optimize for portability: the same vault should work across Codex, CoWork, MCP-compatible tools, and REST API clients.
|
||||
|
||||
## Memory model (where things live)
|
||||
|
||||
- **Working** → `_agent/memory/working/` — transient, time-boxed.
|
||||
- **Episodic** → `_agent/memory/episodic/` — what happened, when.
|
||||
- **Semantic** → `_agent/memory/semantic/` — durable facts, patterns, preferences (`operator-preferences.md`).
|
||||
- **Context bundles** → `_agent/context/` — task-focused reading lists and active state.
|
||||
|
||||
## Safety rules
|
||||
|
||||
- Do not fabricate facts, relationships, or prior decisions.
|
||||
- Do not mass-restructure the vault unless explicitly asked.
|
||||
- Do not delete notes unless deletion is explicitly requested and clearly safe.
|
||||
- Do not store secrets or API keys inside the vault, and never write the API key into a vault note.
|
||||
- Default to additive updates and explicit status changes over destructive edits.
|
||||
|
||||
## REST/API readiness
|
||||
|
||||
Assume clients may operate without filesystem access, through the Obsidian Local REST API. Keep paths predictable, frontmatter parseable, titles stable, and all state stored in notes rather than hidden conversation memory. Structure must stay portable: a fresh, empty vault is brought fully online by the plugin's bootstrap (`references/bootstrap.md`), so nothing essential should depend on files existing in the vault ahead of time.
|
||||
|
||||
## Concurrency (shared vault)
|
||||
|
||||
The vault is a **shared** substrate — Codex, CoWork, and other REST/MCP clients may operate on it concurrently. The REST API offers no transactions, so writers coordinate cooperatively:
|
||||
|
||||
- Single-line, overwrite-style files (`_agent/heartbeat/last-session.md`, `current-context.md::Scope`) and append targets (`inbox.md`, `## Agent Log`) assume **one writer at a time**. Two sessions writing at once can clobber or duplicate.
|
||||
- Before a burst of writes in a session that may overlap another, take the advisory lock (`echo.py lock <id>` → `_agent/locks/vault.lock`) and release it at session end. The lock is cooperative with a TTL (stale locks are reclaimable); it is a courtesy, not a hard mutex.
|
||||
- Idempotent append (read-before-POST, via `echo.py append`) is the second line of defense against duplicate lines from retries or overlapping sessions.
|
||||
- Status-check every write. A write that returns `>= 400` did **not** land — surface it rather than assuming success.
|
||||
@@ -1,113 +0,0 @@
|
||||
# ECHO Routing Map
|
||||
|
||||
**This document is canonical and complete.** Every write destination in the vault appears here exactly once, with the condition that routes content to it, what lands there, and why it is distinct from its neighbours. The rule for the whole system: **if a path is not in this map, nothing is written to it.** A path that cannot justify its separateness from a neighbour is a merge candidate, not a valid destination.
|
||||
|
||||
Views of the same truth: `scripts/routing.json` is the **machine-readable canonical source** (one route per destination, as a regex pattern + trigger + reason); `vault_lint.py` loads it and flags any vault path that matches no route (and any write to a retired path). This prose map is the human-readable authority and must stay in sync with `routing.json`. The `SKILL.md` *Where to Write* table is the quick-reference and `vault-layout.md` is the physical tree. When they disagree, `routing.json` + this map win and the others are fixed to match.
|
||||
|
||||
## How to read a row
|
||||
|
||||
- **Trigger** — the observable condition that sends content here. If two rows could both fire, the more specific trigger wins.
|
||||
- **What lands** — the unit of content written.
|
||||
- **Distinct because** — the one reason this path is not merged into its nearest neighbour. This is the load-bearing column; a row without it is a bug.
|
||||
- **Method** — the dominant REST verb (see `api-reference.md` for mechanics).
|
||||
|
||||
---
|
||||
|
||||
## inbox/ — unsorted intake
|
||||
|
||||
| Path | Trigger | What lands | Distinct because | Method |
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `inbox/captures/inbox.md` | "Save this, sort later" — destination genuinely unknown at capture time | One date-prefixed line | The only path whose contract is *deferred routing*; everything else here has a known home | POST |
|
||||
| `inbox/imports/<slug>.md` | Raw external material dropped in wholesale (export, paste, dump) | The raw artifact, unedited | Holds un-triaged *bulk*, vs `captures` which holds single lines | PUT |
|
||||
| `inbox/processing-log/YYYY-MM-DD.md` | An inbox item is routed to its real home | One line: `<original> → <destination path>` | Audit trail of moves — never holds memory itself, only the record of routing | POST |
|
||||
|
||||
Captures and imports are temporary by contract. Triage drains them into the homes below and logs the move; the original is left until Jason okays deletion.
|
||||
|
||||
## journal/ — the one append-only time-series stream
|
||||
|
||||
Rollups are coarser-grained journal entries over the same timeline, so they live in the same tree. There is no separate `reviews/` tree.
|
||||
|
||||
| Path | Trigger | What lands | Distinct because | Method |
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `journal/daily/YYYY-MM-DD.md` | First agent activity on a given day | `## Agent Log` line(s) + day notes | Finest grain; the only journal note PATCHed repeatedly within its period | PATCH (auto-create) |
|
||||
| `journal/weekly/YYYY-Www.md` | First substantive session of a new ISO week — **opt-in**, offer first | Light digest: open `active/` threads, aging inbox, week's scope changes | Coarser than daily, finer than monthly; ISO-week grain | PUT |
|
||||
| `journal/monthly/YYYY-MM.md` | First substantive session of a new month — offered alongside Vault Health | Month digest, same shape as weekly | Month grain; separate cadence and trigger from weekly | PUT |
|
||||
| `journal/quarterly/YYYY-Qn.md` | **Manual / on request only** | Quarter-scale narrative review | Strategic grain; never auto-fires | PUT |
|
||||
| `journal/annual/YYYY.md` | **Manual / on request only** | Year-scale narrative review | Coarsest grain; never auto-fires | PUT |
|
||||
| `journal/templates/` | Bootstrap only (seeded from plugin masters) | Note templates | Holds templates, not journal content; never a runtime routing target | PUT (seed) |
|
||||
|
||||
## projects/ — work with an end state
|
||||
|
||||
Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter checks this).
|
||||
|
||||
| Path | Trigger | What lands | Distinct because | Method |
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `projects/active/<slug>.md` | Work in motion now | Project note (Status PATCHed fresh) | Default state for anything being worked | PUT + PATCH |
|
||||
| `projects/incubating/<slug>.md` | Idea captured, work not started | Project note | Pre-work; promote to `active/` when work begins | PUT |
|
||||
| `projects/on-hold/<slug>.md` | Paused but still tracked | Project note | Resumable; distinct from `archived` (which is terminal) | PUT |
|
||||
| `projects/archived/<slug>.md` | Done, abandoned, or rolled up | Project note | Terminal; kept for history, never deleted | PUT |
|
||||
|
||||
**Project vs area:** has an end state → `projects/`. Never "done" → `areas/`.
|
||||
|
||||
## areas/ — standing responsibilities, no finish line
|
||||
|
||||
| Path | Trigger | What lands | Distinct because | Method |
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `areas/<domain>/<slug>.md` | Ongoing domain Jason maintains indefinitely (`<domain>`: business/personal/learning/systems) | Area note | No end state — the one thing that disqualifies it from `projects/` | PUT |
|
||||
|
||||
## resources/ — reference material about the world
|
||||
|
||||
| Path | Trigger | What lands | Distinct because | Method |
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `resources/people/<name>.md` | A fact about a specific person | Person note (kebab-case slug) | Keyed to a person, not a topic or event | PUT / PATCH |
|
||||
| `resources/companies/<slug>.md` | A fact about an organization (client, vendor, partner, employer) | Company note (kebab-case slug) | Keyed to an organization, not an individual (`people/`) or an external source (`references/`) | PUT / PATCH |
|
||||
| `resources/concepts/<slug>.md` | A reusable concept/idea Jason wants on file | Concept note | An idea, vs a `reference` which is an external source | PUT |
|
||||
| `resources/references/<slug>.md` | An external source/link worth keeping | Reference note | Points outward (a source), vs `concepts` (an idea) | PUT |
|
||||
| `resources/meetings/YYYY-MM-DD-<slug>.md` | Notes/recap tied to a specific meeting or call | Meeting note; mirror decisions to `decisions/by-date/`, commitments to the project | Event-anchored to a meeting, vs a project's ongoing thread | PUT |
|
||||
|
||||
## decisions/ — non-obvious decisions (ADR)
|
||||
|
||||
| Path | Trigger | What lands | Distinct because | Method |
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `decisions/by-date/YYYY-MM-DD-<slug>.md` | A non-obvious decision worth recording | ADR: Context → Decision → Consequences | The chronological system of record for decisions | PUT |
|
||||
|
||||
**Mirror, don't duplicate:** if the decision belongs to an existing `projects/active/` note, PATCH a `[[wikilink]]` to the ADR under that project's `## Key Decisions`. No matching project → skip the mirror; the by-date ADR stands alone.
|
||||
|
||||
## _agent/ — the agent's own working substrate
|
||||
|
||||
| Path | Trigger | What lands | Distinct because | Method |
|
||||
|------|---------|------------|------------------|--------|
|
||||
| `_agent/echo-vault.md` | Bootstrap / schema migration only | Marker: `schema_version`, bootstrap date | Plugin-owned probe; never hand-edited | GET / PUT |
|
||||
| `_agent/context/current-context.md` | Active scope changes; task focus shifts | `## Scope`, `## Scope History`, priorities | Single *live* scope pointer, vs episodic which is a *past* record | PATCH / PUT |
|
||||
| `_agent/memory/semantic/operator-preferences.md` | A preference/pattern about Jason | Append under `## Observations`; promote to `## Fact / Pattern` when stable | The one curated profile; distinct from ad-hoc semantic notes | PATCH |
|
||||
| `_agent/memory/semantic/<slug>.md` | A durable fact/pattern that isn't a Jason-preference | Semantic note | Timeless fact, vs episodic (time-stamped event) and working (transient) | PUT |
|
||||
| `_agent/memory/episodic/<slug>.md` | A record of *what happened, when* | Episodic note | Anchored to an event in time; not a standing fact | PUT |
|
||||
| `_agent/memory/working/<slug>.md` | Short-lived state needed only for the current effort | Working note | Explicitly transient/time-boxed; safe to go stale | PUT |
|
||||
| `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md` | A substantive session ends (decisions/artifacts/commitments) | Session log (see template) | Per-session record; the unit loading Step 4 scans | PUT |
|
||||
| `_agent/health/YYYY-MM-vault-health.md` | First substantive session of a month (Vault Health pass) | Health-audit findings | Agent self-maintenance about vault integrity — NOT Jason's work narrative, so not in `journal/` | PUT |
|
||||
| `_agent/heartbeat/last-session.md` | End of every session, after the session log is written | One line: `<session-log-path> @ <ISO-timestamp>` | O(1) orientation pointer read first at load (Step 4); overwritten, never grows | PUT |
|
||||
| `_agent/templates/` | Bootstrap only (seeded from plugin masters) | Canonical note templates | Holds templates, not memory; never a runtime routing target | PUT (seed) |
|
||||
| `_agent/skills/active/<slug>.md` | Jason authors/installs a skill and wants it catalogued | Skill capability entry | Catalogs a *capability*, vs `projects/` which tracks the *build effort* | PUT |
|
||||
| `_agent/skills/archived/<slug>.md` | A catalogued skill is retired | Skill entry (moved from `active/`) | Terminal state of the skill catalog | PUT |
|
||||
|
||||
---
|
||||
|
||||
## Retired paths — explicitly never written
|
||||
|
||||
Listed so they are recognised as dead and never recreated. Any one of these appearing in a live vault is a migration miss (see `bootstrap.md` Migrations).
|
||||
|
||||
| Path | Status | Where it went instead |
|
||||
|------|--------|-----------------------|
|
||||
| `reviews/` (weekly/monthly/quarterly/annual) | Retired in schema 2 | Journal rollups → `journal/{weekly,monthly,quarterly,annual}/`; vault-health → `_agent/health/` |
|
||||
| `decisions/by-project/` | Retired in schema 1 | ADR mirrored as a `[[wikilink]]` under the project's `## Key Decisions` |
|
||||
| `archive/` (top-level) | Never existed | Project archival → `projects/archived/`; skill archival → `_agent/skills/archived/` |
|
||||
| `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` / `index.md` (in-vault) | Retired in schema 1 | All control logic lives in the plugin (`references/`), not the vault |
|
||||
|
||||
## Routing decision tree (the calls that get mis-made)
|
||||
|
||||
1. **Destination unknown right now?** → `inbox/captures/`. Known? → route directly; never park a known fact in the inbox.
|
||||
2. **Is it about Jason's work over time?** → `journal/` (pick the grain by cadence). **About the vault's mechanical health?** → `_agent/health/`. These two look similar monthly but answer different questions.
|
||||
3. **Does the effort have an end state?** → `projects/` (folder = `status:`). **No finish line?** → `areas/`.
|
||||
4. **A fact about the world:** timeless → `semantic/`; a thing that happened → `episodic/`; needed only for now → `working/`. A fact about a *person* → `resources/people/`; a fact about an *organization* → `resources/companies/`.
|
||||
5. **A decision:** always `decisions/by-date/`; mirror into a project only if one already exists.
|
||||
6. **A capability (skill/plugin):** `_agent/skills/` catalogs the capability; `projects/` tracks building it. Both can exist for the same skill.
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
# Session Log Template
|
||||
|
||||
Session logs go in: `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md`
|
||||
|
||||
**Filename format is canonical and not optional.** The four-digit local-time HHMM component is what makes session filenames lex-sort in true chronological order — the loading procedure depends on it. Before PUT-ing a new session log, validate the filename matches `^\d{4}-\d{2}-\d{2}-\d{4}-[a-z0-9-]+\.md$`. Legacy session logs without HHMM exist in the vault; do not edit their names, but every new write must use the full form.
|
||||
|
||||
The slug describes what the session was about in 2–5 words, kebab-case.
|
||||
Examples: `2026-06-05-1430-echo-plugin-build.md`, `2026-05-14-0900-q1-review-prep.md`.
|
||||
|
||||
Keep logs focused. Capture the goal, what was read/done, decisions, outputs, open threads, and the next step. This matches the vault's `_agent/templates/session-log-template.md`.
|
||||
|
||||
---
|
||||
|
||||
## Template
|
||||
|
||||
```markdown
|
||||
---
|
||||
type: session-log
|
||||
status: complete
|
||||
created: 2026-06-05T14:30
|
||||
updated: 2026-06-05T14:30
|
||||
tags: [agent, session]
|
||||
agent_written: true
|
||||
source_notes: []
|
||||
session_date: 2026-06-05
|
||||
client: claude-code
|
||||
---
|
||||
|
||||
# Session Log
|
||||
|
||||
## Goal
|
||||
One line — what this session set out to do.
|
||||
|
||||
## Notes Read
|
||||
- [[note]] — why it was relevant
|
||||
(omit if none)
|
||||
|
||||
## Actions Taken
|
||||
Brief narrative or bullets of what was done.
|
||||
|
||||
## Decisions Made
|
||||
- Decision — why
|
||||
(omit section if none)
|
||||
|
||||
## Outputs Created
|
||||
- `path/or/filename` — what it is
|
||||
(omit section if none)
|
||||
|
||||
## Open Threads
|
||||
- [ ] unresolved question or next step
|
||||
(omit section if none)
|
||||
|
||||
## Suggested Next Step
|
||||
One sentence: what to do first next time on this topic.
|
||||
|
||||
## Related
|
||||
- [[wikilinks go here, in the body — never in frontmatter]]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example
|
||||
|
||||
```markdown
|
||||
---
|
||||
type: session-log
|
||||
status: complete
|
||||
created: 2026-06-05T14:30
|
||||
updated: 2026-06-05T14:30
|
||||
tags: [agent, session, plugin]
|
||||
agent_written: true
|
||||
source_notes: ["resources/references/obsidian-local-rest-api.md"]
|
||||
session_date: 2026-06-05
|
||||
client: claude-code
|
||||
---
|
||||
|
||||
# Session Log
|
||||
|
||||
## Goal
|
||||
Build and package the echo-memory CoWork plugin against the live vault.
|
||||
|
||||
## Notes Read
|
||||
- [[BOOTSTRAP]], [[STRUCTURE]], [[_agent/memory/semantic/operator-preferences]]
|
||||
|
||||
## Actions Taken
|
||||
Verified the REST API end-to-end, confirmed the scaffold copied into the live vault,
|
||||
created missing empty folders, and built the plugin (SKILL + 4 reference files).
|
||||
|
||||
## Decisions Made
|
||||
- Vault addressed at root — ECHO is a dedicated vault.
|
||||
- Key hardcoded in the plugin (not in the vault) — personal plugin, per the reference pattern.
|
||||
|
||||
## Outputs Created
|
||||
- `echo-memory.plugin` — installable CoWork plugin
|
||||
|
||||
## Open Threads
|
||||
- [ ] Validate Codex direct filesystem access to the vault host.
|
||||
|
||||
## Suggested Next Step
|
||||
Install the plugin and run a live load/write through it to confirm the skill triggers.
|
||||
|
||||
## Related
|
||||
- [[projects/active/vault-foundation]]
|
||||
- [[resources/references/obsidian-local-rest-api]]
|
||||
```
|
||||
|
||||
After writing the log, append a one-line entry to the daily note's **Agent Log** section.
|
||||
|
||||
> **Reminder:** wiki links go in the body (e.g. this `## Related` section), never in YAML
|
||||
> frontmatter. `source_notes` in frontmatter holds plain relative path strings, not `[[links]]`.
|
||||
@@ -1,179 +0,0 @@
|
||||
# Vault Layout & Frontmatter Conventions
|
||||
|
||||
**This document is canonical.** The ECHO vault holds data only — there are no `CLAUDE.md` / `STRUCTURE.md` / `BOOTSTRAP.md` / `index.md` control docs in it. Layout, taxonomy, and frontmatter conventions live here in the plugin; the bootstrap procedure (`references/bootstrap.md`) builds the tree below into any empty vault.
|
||||
|
||||
## Folder Map (root-addressed)
|
||||
|
||||
```
|
||||
/vault/
|
||||
├── README.md ← thin human signpost (NOT read for routing)
|
||||
├── inbox/
|
||||
│ ├── captures/ ← quick captures (inbox.md), date-prefixed lines
|
||||
│ ├── imports/ ← raw imported material
|
||||
│ └── processing-log/
|
||||
├── journal/ ← one append-only time-series stream; rollups are coarser journal entries, NOT a separate reviews/ tree
|
||||
│ ├── daily/ ← YYYY-MM-DD.md (has an "Agent Log" section)
|
||||
│ ├── weekly/ ← YYYY-Www.md (ISO week, opt-in rollup)
|
||||
│ ├── monthly/ ← YYYY-MM.md (monthly rollup)
|
||||
│ ├── quarterly/ ← YYYY-Qn.md (manual / on request)
|
||||
│ ├── annual/ ← YYYY.md (manual / on request)
|
||||
│ └── templates/
|
||||
├── projects/ ← lifecycle: incubating → active → on-hold/archived
|
||||
│ ├── active/ ← current work (status: active)
|
||||
│ ├── incubating/ ← idea captured, not yet started (status: incubating)
|
||||
│ ├── on-hold/ ← paused but kept (status: on-hold)
|
||||
│ ├── archived/ ← done / abandoned (status: archived)
|
||||
│ └── project-template.md
|
||||
├── areas/ ← business / personal / learning / systems
|
||||
├── resources/
|
||||
│ ├── companies/ ← <slug>.md — organizations (clients, vendors, partners, employers)
|
||||
│ ├── concepts/ references/ meetings/
|
||||
│ └── people/ ← <name>.md
|
||||
├── decisions/
|
||||
│ ├── by-date/ ← YYYY-MM-DD-<slug>.md (ADR-style) — the canonical home
|
||||
│ └── decision-template.md
|
||||
│ (decisions/by-project/ is retired and not created —
|
||||
│ mirror an ADR into a project's `## Key Decisions` heading instead)
|
||||
│ (reviews/ is retired — journal rollups live under journal/; vault-health audits under _agent/health/)
|
||||
└── _agent/
|
||||
├── echo-vault.md ← bootstrap marker: schema_version + bootstrap date (plugin-owned; the "is this vault set up?" probe)
|
||||
├── context/ ← current-context.md and task bundles
|
||||
├── memory/
|
||||
│ ├── working/ ← transient, time-boxed
|
||||
│ ├── episodic/ ← what happened, when
|
||||
│ └── semantic/ ← durable facts/patterns (operator-preferences.md)
|
||||
├── sessions/ ← YYYY-MM-DD-HHMM-<slug>.md
|
||||
├── health/ ← YYYY-MM-vault-health.md (monthly self-maintenance audit; NOT a journal entry)
|
||||
├── templates/ ← canonical note templates
|
||||
├── skills/ ← active / archived
|
||||
└── heartbeat/ ← single-line pointers (e.g. last-session.md → most-recent session log path)
|
||||
```
|
||||
|
||||
**Heartbeat:** `_agent/heartbeat/last-session.md` is a one-line pointer (`<session-log-path> @ <ISO-timestamp>`) the **session-logging procedure writes (PUT, overwrite) at session end** and the **loading procedure reads first (Step 4)** as an O(1) shortcut to the latest session log. It is a hint, not a source of truth — fall back to the `sessions/` directory listing if it's missing or stale. Because it's PUT-overwritten, it never grows or duplicates.
|
||||
|
||||
**Slug rules:** kebab-case, ASCII only, truncate to ~40 chars.
|
||||
|
||||
---
|
||||
|
||||
## Canonical Frontmatter
|
||||
|
||||
Every note starts with this block. Fill what applies; leave the rest empty rather than guessing.
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: # see Note Types below
|
||||
status: # active | draft | done | archived | complete
|
||||
created: # YYYY-MM-DD (or YYYY-MM-DDTHH:mm for sessions)
|
||||
updated: # YYYY-MM-DD
|
||||
tags: []
|
||||
agent_written: false
|
||||
source_notes: [] # plain relative paths as strings — NEVER [[wikilinks]]
|
||||
---
|
||||
```
|
||||
|
||||
`agent_written: true` + a populated `source_notes` is the key signal separating
|
||||
agent-managed content from human-authored content. When appending with POST, do
|
||||
not rewrite frontmatter — the append goes after existing content. To change
|
||||
`updated:` or `status:`, use PATCH with `Target-Type: frontmatter`.
|
||||
|
||||
**Frontmatter field semantics:**
|
||||
|
||||
- `created:` is the **earliest known date** the entity was tracked in the vault — *not* "today". When merging notes (e.g. promoting an `on-hold/` project into `active/`), preserve the earliest `created:` from any merged source and only update `updated:`.
|
||||
- `source_notes` is a **backward link** — the note(s) that triggered or supplied content for this one (e.g. the session log a project update came from). Forward links go in the `## Related` body section, never here.
|
||||
- `status:` for a project MUST match its folder under `projects/` (`active`, `incubating`, `on-hold`, `archived`). Moving the file and updating `status:` are the same operation.
|
||||
|
||||
> **No `[[wikilinks]]` in frontmatter.** YAML parses `[[...]]` as nested lists, so wiki
|
||||
> links there break and never render as clickable links in reading view. Put all
|
||||
> cross-references in a **`## Related`** section in the note **body** (bulleted `[[links]]`).
|
||||
> Frontmatter holds scalar/string metadata only; `source_notes` values are plain relative
|
||||
> paths, not links.
|
||||
|
||||
## Note Types
|
||||
|
||||
`daily-note`, `weekly-note`, `monthly-note`, `project`, `project-update`, `area`,
|
||||
`concept`, `reference`, `person`, `company`, `meeting`, `decision`, `review`, `session-log`,
|
||||
`working-memory`, `episodic-memory`, `semantic-memory`, `context-bundle`, `skill`,
|
||||
`draft`, `inbox-item`.
|
||||
|
||||
---
|
||||
|
||||
## File-Specific Conventions
|
||||
|
||||
### operator-preferences.md (`_agent/memory/semantic/`)
|
||||
|
||||
The profile analog. Canonical headings:
|
||||
|
||||
- `## Operator` — who Jason is (one paragraph)
|
||||
- `## Fact / Pattern` — **promoted, deduped rules.** No date prefix. Timeless.
|
||||
- `## Observations` — **timestamped raw observations.** Date-prefixed lines (`- 2026-06-06: ...`). Default landing zone for new evidence.
|
||||
- `## Evidence` — citations/links supporting the rules
|
||||
- `## Recommendation or Implication` — how the rules should shape behavior
|
||||
- `## Review Notes` — confidence / last review date
|
||||
|
||||
Append observed facts under `## Observations` by default. Promote to `## Fact / Pattern` (dropping the date) once a pattern stabilizes. "The operator" is Jason — he is both operator and architect of this vault.
|
||||
|
||||
### projects/active/\<slug\>.md
|
||||
|
||||
Mirrors `scaffold/templates/projects/project-template.md` — keep this block in sync with that template.
|
||||
|
||||
```markdown
|
||||
---
|
||||
type: project
|
||||
status: active
|
||||
created: {{date:YYYY-MM-DD}}
|
||||
updated: {{date:YYYY-MM-DD}}
|
||||
tags: [project]
|
||||
agent_written: false
|
||||
source_notes: []
|
||||
owner:
|
||||
review_cycle: weekly
|
||||
---
|
||||
|
||||
# Project Name
|
||||
|
||||
## Purpose
|
||||
|
||||
## Status
|
||||
One paragraph, kept fresh via PATCH replace (target `Project Name::Status`).
|
||||
|
||||
## Goals
|
||||
|
||||
## Current Context
|
||||
|
||||
## Open Loops
|
||||
- [ ] unresolved thing
|
||||
|
||||
## Key Decisions
|
||||
- [[YYYY-MM-DD-decision-slug]] — one-line summary (ADR mirror target)
|
||||
|
||||
## Related Notes
|
||||
|
||||
## Session History
|
||||
- 2026-06-05: observation or update
|
||||
|
||||
## Related
|
||||
- [[areas/business/business-ops]]
|
||||
```
|
||||
|
||||
### sessions/YYYY-MM-DD-HHMM-\<slug\>.md
|
||||
|
||||
See `session-log-template.md`. ECHO uses an **HHMM time component** in the filename — this is **canonical, not optional**. The four-digit local-time component makes filenames lex-sort in true chronological order, which the loading procedure relies on. Older session logs without HHMM exist; leave them alone, but every new one must use the full `YYYY-MM-DD-HHMM-<slug>.md` form.
|
||||
|
||||
### decisions/by-date/YYYY-MM-DD-\<slug\>.md
|
||||
|
||||
ADR-style: Context → Decision → Consequences. If the decision belongs to an existing project, PATCH-append the wikilink into that project's `## Key Decisions` heading. Don't use `decisions/by-project/` — it's legacy scaffolding that's intentionally left empty.
|
||||
|
||||
### people/\<name\>.md
|
||||
|
||||
`type: person`. Use lowercase kebab-case for the slug (e.g. `jason-stedwell.md`).
|
||||
|
||||
### companies/\<slug\>.md
|
||||
|
||||
`type: company`. An organization Jason works with — client, vendor, partner, or employer (e.g. `gillig.md`, `mpm.md`). Distinct from `people/` (individuals, who *belong to* companies) and `references/` (external sources). Lowercase kebab-case slug.
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
Use Obsidian wiki links freely: `[[note-name]]` or `[[folder/note]]`. The REST API
|
||||
doesn't resolve them, but Obsidian does when the operator browses the vault.
|
||||
@@ -1,12 +0,0 @@
|
||||
# ECHO Memory Vault
|
||||
|
||||
This Obsidian vault is the persistent memory substrate ("second brain") for its operator. It is read and written across Codex / CoWork sessions through the Obsidian Local REST API.
|
||||
|
||||
**This vault holds data, not logic.** All operating procedure — how the vault is bootstrapped, how notes are routed, the taxonomy, frontmatter conventions, and safety rules — lives in the **`echo-memory` plugin**, which is the single source of truth. There are intentionally no `CLAUDE.md` / `BOOTSTRAP.md` / `STRUCTURE.md` control docs in this vault; updating or porting ECHO means updating or installing the plugin, not editing files here.
|
||||
|
||||
- **Layout:** see the plugin's `references/vault-layout.md`.
|
||||
- **Operating contract & safety:** see the plugin's `references/operating-contract.md`.
|
||||
- **Bootstrap / repair:** see the plugin's `references/bootstrap.md`.
|
||||
- **Version marker:** `_agent/echo-vault.md` records the schema version and bootstrap date.
|
||||
|
||||
Folders: `inbox/`, `journal/` (daily + weekly/monthly/quarterly/annual rollups), `projects/`, `areas/`, `resources/`, `decisions/`, and the agent subtree `_agent/`.
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
---
|
||||
type: context-bundle
|
||||
status: active
|
||||
created: {{DATE}}
|
||||
updated: {{DATE}}
|
||||
tags: [agent, context]
|
||||
agent_written: true
|
||||
source_notes: []
|
||||
scope:
|
||||
scope_updated: {{DATE}}
|
||||
refresh_strategy: on-demand
|
||||
---
|
||||
|
||||
# Current Context
|
||||
|
||||
## Scope
|
||||
<!-- The single active scope. Replaced (PATCH replace) on each scope switch. -->
|
||||
|
||||
## Scope History
|
||||
<!-- Dated bullets of prior scopes, newest first: `- {{DATE}}: <prior scope summary>`. Trim to ~10. -->
|
||||
|
||||
## Active Priorities
|
||||
|
||||
## Open Questions
|
||||
|
||||
## Related
|
||||
@@ -1,3 +0,0 @@
|
||||
# Inbox — Captures
|
||||
|
||||
Quick captures land here as date-prefixed lines (`- {{DATE}}: <thing>`), one per line, via POST. Triage routes durable items to their proper home (see the echo-memory skill's Inbox Triage). Don't delete originals on triage — the processing log is the audit trail.
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
---
|
||||
type: semantic-memory
|
||||
status: active
|
||||
created: {{DATE}}
|
||||
updated: {{DATE}}
|
||||
tags: [agent, semantic-memory, operator]
|
||||
agent_written: true
|
||||
source_notes: []
|
||||
confidence: low
|
||||
last_reviewed: {{DATE}}
|
||||
---
|
||||
|
||||
# Operator Preferences
|
||||
|
||||
## Operator
|
||||
<!-- One paragraph: who the operator is. Leave for the operator to confirm; do not fabricate. -->
|
||||
|
||||
## Fact / Pattern
|
||||
<!-- Promoted, deduped, timeless rules. No date prefix. Add only when a rule is stable. -->
|
||||
|
||||
## Observations
|
||||
<!-- Timestamped raw observations. Default landing zone for new evidence: `- {{DATE}}: ...` -->
|
||||
|
||||
## Evidence
|
||||
|
||||
## Recommendation or Implication
|
||||
|
||||
## Review Notes
|
||||
Seeded empty at bootstrap ({{DATE}}). Raise confidence once preferences are confirmed through day-to-day use.
|
||||
|
||||
## Related
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
type: reference
|
||||
status: active
|
||||
created: {{DATE}}
|
||||
updated: {{DATE}}
|
||||
tags: [agent, system, marker]
|
||||
agent_written: true
|
||||
source_notes: []
|
||||
schema_version: 2
|
||||
bootstrapped: {{DATE}}
|
||||
managed_by: echo-memory-plugin
|
||||
---
|
||||
|
||||
# ECHO Vault Marker
|
||||
|
||||
This file marks the vault as bootstrapped by the **echo-memory plugin** and records its schema version. The plugin's loading procedure GETs this file as its "is this vault set up?" probe; a `404` triggers a fresh bootstrap.
|
||||
|
||||
- `schema_version` — bumped by the plugin when the vault layout changes; a mismatch is the hook for a migration pass (see `references/bootstrap.md`).
|
||||
- Do not hand-edit. The plugin owns this file.
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
---
|
||||
type: context-bundle
|
||||
status: active
|
||||
created: {{date:YYYY-MM-DD}}
|
||||
updated: {{date:YYYY-MM-DD}}
|
||||
tags: [agent, context]
|
||||
agent_written: true
|
||||
source_notes: []
|
||||
scope:
|
||||
refresh_strategy: on-demand
|
||||
---
|
||||
|
||||
# Context Bundle Title
|
||||
|
||||
## Scope
|
||||
|
||||
## Active Priorities
|
||||
|
||||
## Relevant Entities
|
||||
|
||||
## Key Decisions
|
||||
|
||||
## Open Questions
|
||||
|
||||
## Source Notes
|
||||
|
||||
## Related
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
---
|
||||
type: semantic-memory
|
||||
status: active
|
||||
created: {{date:YYYY-MM-DD}}
|
||||
updated: {{date:YYYY-MM-DD}}
|
||||
tags: [agent, semantic-memory]
|
||||
agent_written: true
|
||||
source_notes: []
|
||||
confidence: high
|
||||
last_reviewed: {{date:YYYY-MM-DD}}
|
||||
---
|
||||
|
||||
# Semantic Memory Title
|
||||
|
||||
## Fact / Pattern
|
||||
|
||||
## Evidence
|
||||
|
||||
## Recommendation or Implication
|
||||
|
||||
## Review Notes
|
||||
|
||||
## Related
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
---
|
||||
type: session-log
|
||||
status: complete
|
||||
created: {{date:YYYY-MM-DDTHH:mm}}
|
||||
updated: {{date:YYYY-MM-DDTHH:mm}}
|
||||
tags: [agent, session]
|
||||
agent_written: true
|
||||
source_notes: []
|
||||
session_date: {{date:YYYY-MM-DD}}
|
||||
client: claude-code
|
||||
---
|
||||
|
||||
# Session Log
|
||||
|
||||
## Goal
|
||||
|
||||
## Notes Read
|
||||
|
||||
## Actions Taken
|
||||
|
||||
## Decisions Made
|
||||
|
||||
## Outputs Created
|
||||
|
||||
## Open Threads
|
||||
|
||||
## Suggested Next Step
|
||||
|
||||
## Related
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
---
|
||||
type: working-memory
|
||||
status: active
|
||||
created: {{date:YYYY-MM-DDTHH:mm}}
|
||||
updated: {{date:YYYY-MM-DDTHH:mm}}
|
||||
tags: [agent, working-memory]
|
||||
agent_written: true
|
||||
source_notes: []
|
||||
expires_after: 48h
|
||||
---
|
||||
|
||||
# Working Context
|
||||
|
||||
## Active Focus
|
||||
|
||||
## Recent Decisions
|
||||
|
||||
## Open Threads
|
||||
|
||||
## Relevant Links
|
||||
|
||||
## Related
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
---
|
||||
type: decision
|
||||
status: complete
|
||||
created: {{date:YYYY-MM-DD}}
|
||||
updated: {{date:YYYY-MM-DD}}
|
||||
tags: [decision]
|
||||
agent_written: true
|
||||
source_notes: []
|
||||
decision_date: {{date:YYYY-MM-DD}}
|
||||
impact: medium
|
||||
---
|
||||
|
||||
# Decision Title
|
||||
|
||||
## Context
|
||||
|
||||
## Decision
|
||||
|
||||
## Rationale
|
||||
|
||||
## Consequences
|
||||
|
||||
## Follow-Up
|
||||
|
||||
## Related
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
---
|
||||
type: daily-note
|
||||
status: active
|
||||
created: {{date:YYYY-MM-DD}}
|
||||
updated: {{date:YYYY-MM-DD}}
|
||||
tags: [daily, journal]
|
||||
agent_written: false
|
||||
source_notes: []
|
||||
---
|
||||
|
||||
# {{date:YYYY-MM-DD}}
|
||||
|
||||
## Top Priorities
|
||||
|
||||
## Schedule / Commitments
|
||||
|
||||
## Open Loops
|
||||
|
||||
## Notes
|
||||
|
||||
## Context for AI
|
||||
|
||||
## Agent Log
|
||||
|
||||
## Related
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
---
|
||||
type: review
|
||||
status: active
|
||||
created: {{date:gggg-[W]WW}}
|
||||
updated: {{date:gggg-[W]WW}}
|
||||
tags: [review, weekly]
|
||||
agent_written: true
|
||||
source_notes: []
|
||||
period: weekly
|
||||
---
|
||||
|
||||
# Weekly Review
|
||||
|
||||
## Completed Work
|
||||
|
||||
## Open Loops
|
||||
|
||||
## Stale Projects
|
||||
|
||||
## Decisions
|
||||
|
||||
## Priorities Next Week
|
||||
|
||||
## Agent Notes
|
||||
|
||||
## Related
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
---
|
||||
type: project
|
||||
status: active
|
||||
created: {{date:YYYY-MM-DD}}
|
||||
updated: {{date:YYYY-MM-DD}}
|
||||
tags: [project]
|
||||
agent_written: false
|
||||
source_notes: []
|
||||
owner:
|
||||
review_cycle: weekly
|
||||
---
|
||||
|
||||
# Project Name
|
||||
|
||||
## Purpose
|
||||
|
||||
## Status
|
||||
|
||||
## Goals
|
||||
|
||||
## Current Context
|
||||
|
||||
## Open Loops
|
||||
|
||||
## Key Decisions
|
||||
|
||||
## Related Notes
|
||||
|
||||
## Session History
|
||||
|
||||
## Related
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bootstrap or repair an ECHO vault from the bundled scaffold."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
SKILL_DIR = SCRIPT_DIR.parent
|
||||
SCAFFOLD = SKILL_DIR / "scaffold"
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import echo # noqa: E402
|
||||
|
||||
|
||||
LEAVES = [
|
||||
"inbox/captures",
|
||||
"inbox/imports",
|
||||
"inbox/processing-log",
|
||||
"journal/daily",
|
||||
"journal/weekly",
|
||||
"journal/monthly",
|
||||
"journal/quarterly",
|
||||
"journal/annual",
|
||||
"journal/templates",
|
||||
"projects/active",
|
||||
"projects/incubating",
|
||||
"projects/on-hold",
|
||||
"projects/archived",
|
||||
"areas/business",
|
||||
"areas/personal",
|
||||
"areas/learning",
|
||||
"areas/systems",
|
||||
"resources/concepts",
|
||||
"resources/references",
|
||||
"resources/people",
|
||||
"resources/companies",
|
||||
"resources/meetings",
|
||||
"decisions/by-date",
|
||||
"_agent/context",
|
||||
"_agent/memory/working",
|
||||
"_agent/memory/episodic",
|
||||
"_agent/memory/semantic",
|
||||
"_agent/sessions",
|
||||
"_agent/health",
|
||||
"_agent/templates",
|
||||
"_agent/heartbeat",
|
||||
"_agent/skills/active",
|
||||
"_agent/skills/archived",
|
||||
"_agent/locks",
|
||||
]
|
||||
|
||||
|
||||
def exists(path: str) -> bool:
|
||||
status, _ = echo.request("GET", echo.vault_url(path))
|
||||
return status == 200
|
||||
|
||||
|
||||
def put_text(path: str, text: str) -> None:
|
||||
status, body = echo.request(
|
||||
"PUT",
|
||||
echo.vault_url(path),
|
||||
data=text.encode(),
|
||||
headers={"Content-Type": "text/markdown"},
|
||||
)
|
||||
echo.check(status, body, f"bootstrap put {path}")
|
||||
|
||||
|
||||
def seed(path: str, source: Path, dry_run: bool) -> None:
|
||||
if exists(path):
|
||||
print(f"bootstrap: skip (exists) {path}")
|
||||
return
|
||||
if dry_run:
|
||||
print(f"bootstrap: would seed {path} <- {source.relative_to(SKILL_DIR)}")
|
||||
return
|
||||
text = source.read_text(encoding="utf-8").replace("{{DATE}}", echo.today())
|
||||
put_text(path, text)
|
||||
print(f"bootstrap: seeded {path}")
|
||||
|
||||
|
||||
def leaf_readme(folder: str, dry_run: bool) -> None:
|
||||
path = f"{folder}/README.md"
|
||||
if exists(path):
|
||||
return
|
||||
if dry_run:
|
||||
print(f"bootstrap: would readme {path}")
|
||||
return
|
||||
name = folder.rsplit("/", 1)[-1]
|
||||
put_text(path, f"# {name}\n\nMemory vault folder. See the echo-memory plugin for conventions.\n")
|
||||
print(f"bootstrap: readme {path}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Bootstrap or repair an ECHO vault")
|
||||
parser.add_argument("--dry-run", "-n", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not SCAFFOLD.is_dir():
|
||||
print(f"bootstrap: scaffold not found at {SCAFFOLD}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
marker = exists("_agent/echo-vault.md")
|
||||
if marker:
|
||||
try:
|
||||
status, body = echo.request("GET", echo.vault_url("_agent/echo-vault.md"))
|
||||
echo.check(status, body, "bootstrap marker")
|
||||
version = next((line.split(":", 1)[1].strip() for line in body.decode().splitlines() if line.startswith("schema_version:")), "unknown")
|
||||
except Exception:
|
||||
version = "unknown"
|
||||
print(f"bootstrap: marker present (schema_version={version}). Running repair pass.")
|
||||
|
||||
for folder in LEAVES:
|
||||
leaf_readme(folder, args.dry_run)
|
||||
|
||||
templates = SCAFFOLD / "templates"
|
||||
if templates.is_dir():
|
||||
for source in sorted(templates.rglob("*.md")):
|
||||
seed(source.relative_to(templates).as_posix(), source, args.dry_run)
|
||||
|
||||
seed("_agent/memory/semantic/operator-preferences.md", SCAFFOLD / "anchors" / "operator-preferences.seed.md", args.dry_run)
|
||||
seed("_agent/context/current-context.md", SCAFFOLD / "anchors" / "current-context.seed.md", args.dry_run)
|
||||
seed("inbox/captures/inbox.md", SCAFFOLD / "anchors" / "inbox.seed.md", args.dry_run)
|
||||
seed("README.md", SCAFFOLD / "README.vault.md", args.dry_run)
|
||||
seed("_agent/echo-vault.md", SCAFFOLD / "echo-vault.md", args.dry_run)
|
||||
|
||||
print(f"bootstrap: done ({'DRY-RUN ' if args.dry_run else ''}{echo.today()}).")
|
||||
print("bootstrap: next: create today's daily note, bootstrap session log, and heartbeat if this is a first run.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,192 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write ECHO session closeout artifacts.
|
||||
|
||||
Creates or updates the daily Agent Log, writes a canonical session log, and
|
||||
updates the heartbeat pointer. This keeps the logging workflow explicit while
|
||||
making it hard to skip after substantive memory writes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
SKILL_DIR = SCRIPT_DIR.parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import echo # noqa: E402
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
||||
slug = re.sub(r"-{2,}", "-", slug)
|
||||
return slug[:60].strip("-") or "session"
|
||||
|
||||
|
||||
def now_local() -> dt.datetime:
|
||||
override = echo.today()
|
||||
current = dt.datetime.now()
|
||||
try:
|
||||
date = dt.date.fromisoformat(override)
|
||||
return current.replace(year=date.year, month=date.month, day=date.day)
|
||||
except ValueError:
|
||||
return current
|
||||
|
||||
|
||||
def temp_file(text: str) -> str:
|
||||
handle = tempfile.NamedTemporaryFile("w", encoding="utf-8", newline="\n", delete=False)
|
||||
try:
|
||||
handle.write(text)
|
||||
handle.close()
|
||||
return handle.name
|
||||
finally:
|
||||
try:
|
||||
handle.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_text(path: str) -> str | None:
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 404:
|
||||
return None
|
||||
echo.check(status, body, f"get {path}")
|
||||
return body.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def put_text(path: str, text: str) -> None:
|
||||
echo.cmd_put(path, temp_file(text))
|
||||
|
||||
|
||||
def patch_heading(path: str, op: str, target: str, text: str) -> None:
|
||||
echo.cmd_patch(path, op, "heading", target, temp_file(text))
|
||||
|
||||
|
||||
def ensure_daily_note(date: str) -> str:
|
||||
path = f"journal/daily/{date}.md"
|
||||
text = get_text(path)
|
||||
if text is None:
|
||||
template_path = SKILL_DIR / "scaffold" / "templates" / "journal" / "templates" / "daily-note-template.md"
|
||||
template = template_path.read_text(encoding="utf-8")
|
||||
body = template.replace("{{date:YYYY-MM-DD}}", date).replace("{{DATE}}", date)
|
||||
put_text(path, body)
|
||||
text = body
|
||||
if not re.search(r"(?m)^## Agent Log\s*$", text):
|
||||
echo.cmd_post(path, temp_file("\n\n## Agent Log\n"))
|
||||
return path
|
||||
|
||||
|
||||
def append_daily_log(date: str, line: str) -> str:
|
||||
path = ensure_daily_note(date)
|
||||
target = f"{date}::Agent Log"
|
||||
entry = line if line.startswith("- ") else f"- {date}: {line}"
|
||||
existing = get_text(path) or ""
|
||||
if entry in existing:
|
||||
print(f"skip: daily Agent Log already contains entry for {path}")
|
||||
return path
|
||||
patch_heading(path, "append", target, entry)
|
||||
return path
|
||||
|
||||
|
||||
def bullet_section(items: list[str]) -> str:
|
||||
return "\n".join(f"- {item}" for item in items)
|
||||
|
||||
|
||||
def render_session_log(args: argparse.Namespace, created: dt.datetime, session_path: str) -> str:
|
||||
iso_minute = created.strftime("%Y-%m-%dT%H:%M")
|
||||
date = created.date().isoformat()
|
||||
related = args.related or []
|
||||
notes = args.notes_read or []
|
||||
outputs = args.outputs or []
|
||||
decisions = args.decisions or []
|
||||
open_threads = args.open_threads or []
|
||||
sections = [
|
||||
"---",
|
||||
"type: session-log",
|
||||
"status: complete",
|
||||
f"created: {iso_minute}",
|
||||
f"updated: {iso_minute}",
|
||||
"tags: [agent, session, codex]",
|
||||
"agent_written: true",
|
||||
"source_notes: []",
|
||||
f"session_date: {date}",
|
||||
"client: codex",
|
||||
"---",
|
||||
"",
|
||||
"# Session Log",
|
||||
"",
|
||||
"## Goal",
|
||||
args.goal.strip(),
|
||||
"",
|
||||
]
|
||||
if notes:
|
||||
sections.extend(["## Notes Read", bullet_section(notes), ""])
|
||||
sections.extend(["## Actions Taken", args.actions.strip(), ""])
|
||||
if decisions:
|
||||
sections.extend(["## Decisions Made", bullet_section(decisions), ""])
|
||||
if outputs:
|
||||
sections.extend(["## Outputs Created", bullet_section(outputs), ""])
|
||||
if open_threads:
|
||||
sections.extend(["## Open Threads", "\n".join(f"- [ ] {item}" for item in open_threads), ""])
|
||||
sections.extend(["## Suggested Next Step", args.next_step.strip(), ""])
|
||||
if related:
|
||||
sections.extend(["## Related", bullet_section(related), ""])
|
||||
sections.append(f"Session path: `{session_path}`")
|
||||
return "\n".join(sections).rstrip() + "\n"
|
||||
|
||||
|
||||
def write_session_log(args: argparse.Namespace, created: dt.datetime) -> str:
|
||||
date = created.date().isoformat()
|
||||
hhmm = created.strftime("%H%M")
|
||||
slug = slugify(args.slug or args.goal)
|
||||
filename = f"{date}-{hhmm}-{slug}.md"
|
||||
if not re.match(r"^\d{4}-\d{2}-\d{2}-\d{4}-[a-z0-9-]+\.md$", filename):
|
||||
raise echo.EchoError(f"invalid session filename: {filename}", 2)
|
||||
path = f"_agent/sessions/{filename}"
|
||||
existing = get_text(path)
|
||||
if existing is None:
|
||||
put_text(path, render_session_log(args, created, path))
|
||||
else:
|
||||
print(f"skip: session log already exists at {path}")
|
||||
return path
|
||||
|
||||
|
||||
def update_heartbeat(session_path: str, created: dt.datetime) -> None:
|
||||
stamp = created.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
put_text("_agent/heartbeat/last-session.md", f"{session_path} @ {stamp}\n")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Write ECHO session closeout artifacts")
|
||||
parser.add_argument("--slug", help="2-5 word kebab-case-ish session topic")
|
||||
parser.add_argument("--goal", required=True)
|
||||
parser.add_argument("--actions", required=True)
|
||||
parser.add_argument("--next-step", required=True)
|
||||
parser.add_argument("--daily-line", required=True)
|
||||
parser.add_argument("--related", action="append", default=[])
|
||||
parser.add_argument("--notes-read", action="append", default=[])
|
||||
parser.add_argument("--outputs", action="append", default=[])
|
||||
parser.add_argument("--decisions", action="append", default=[])
|
||||
parser.add_argument("--open-threads", action="append", default=[])
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
created = now_local()
|
||||
session_path = write_session_log(args, created)
|
||||
daily_path = append_daily_log(created.date().isoformat(), args.daily_line)
|
||||
update_heartbeat(session_path, created)
|
||||
print(f"ok: session log {session_path}")
|
||||
print(f"ok: daily log {daily_path}")
|
||||
print("ok: heartbeat updated")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,376 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validated client for the ECHO Obsidian Local REST API.
|
||||
|
||||
This is the Codex-portable replacement for the original bash echo.py client.
|
||||
It intentionally requires ECHO_KEY from the environment; no bearer token is
|
||||
stored in the plugin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import locale
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BASE = os.environ.get("ECHO_BASE", "https://echoapi.alwisp.com").rstrip("/")
|
||||
KEY = os.environ.get("ECHO_KEY", "")
|
||||
VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0"
|
||||
LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900"))
|
||||
|
||||
|
||||
class EchoError(RuntimeError):
|
||||
def __init__(self, message: str, code: int = 1) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def require_key() -> None:
|
||||
if not KEY:
|
||||
raise EchoError("ECHO_KEY is not set; refusing to call the live vault")
|
||||
|
||||
|
||||
def today() -> str:
|
||||
return os.environ.get("ECHO_TODAY") or dt.date.today().isoformat()
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def vault_url(path: str) -> str:
|
||||
safe = "/".join(urllib.parse.quote(part) for part in path.split("/"))
|
||||
if path.endswith("/") and not safe.endswith("/"):
|
||||
safe += "/"
|
||||
return f"{BASE}/vault/{safe}"
|
||||
|
||||
|
||||
def request(method: str, url: str, data: bytes | None = None, headers: dict[str, str] | None = None) -> tuple[int, bytes]:
|
||||
require_key()
|
||||
merged = {"Authorization": f"Bearer {KEY}", **(headers or {})}
|
||||
last_status = 0
|
||||
last_body = b""
|
||||
for attempt in range(2):
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=merged)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
return response.status, response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read()
|
||||
if exc.code >= 500 and attempt == 0:
|
||||
time.sleep(1)
|
||||
continue
|
||||
return exc.code, body
|
||||
except Exception as exc:
|
||||
last_status, last_body = 0, str(exc).encode()
|
||||
if attempt == 0:
|
||||
time.sleep(1)
|
||||
continue
|
||||
return last_status, last_body
|
||||
|
||||
|
||||
def check(status: int, body: bytes, context: str) -> None:
|
||||
if status == 404:
|
||||
raise EchoError(f"{context}: not found", 44)
|
||||
if status == 0:
|
||||
raise EchoError(f"{context}: vault unreachable ({body.decode(errors='replace')})", 1)
|
||||
if status >= 400:
|
||||
raise EchoError(f"{context}: HTTP {status} - {body.decode(errors='replace')}", 1)
|
||||
|
||||
|
||||
def read_body(arg: str | None) -> bytes:
|
||||
if arg in (None, "-"):
|
||||
raw = sys.stdin.buffer.read()
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = raw.decode(locale.getpreferredencoding(False) or "cp1252", errors="replace")
|
||||
return text.encode("utf-8")
|
||||
return Path(arg).read_bytes()
|
||||
|
||||
|
||||
def normalize_patch_body(data: bytes, op: str, target_type: str) -> bytes:
|
||||
if target_type != "heading":
|
||||
return data
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
if op == "replace":
|
||||
text = text.strip("\r\n")
|
||||
if text:
|
||||
text = f"\n{text}\n"
|
||||
else:
|
||||
text = "\n"
|
||||
elif text and not text.endswith("\n"):
|
||||
text += "\n"
|
||||
return text.encode("utf-8")
|
||||
|
||||
|
||||
def normalize_json_scalar(value: str) -> bytes:
|
||||
value = value.strip()
|
||||
try:
|
||||
json.loads(value)
|
||||
return value.encode("utf-8")
|
||||
except json.JSONDecodeError:
|
||||
return json.dumps(value).encode("utf-8")
|
||||
|
||||
|
||||
def cmd_get(path: str) -> int:
|
||||
status, body = request("GET", vault_url(path))
|
||||
check(status, body, f"get {path}")
|
||||
sys.stdout.buffer.write(body)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_map(path: str) -> int:
|
||||
status, body = request("GET", vault_url(path), headers={"Accept": "application/vnd.olrapi.document-map+json"})
|
||||
check(status, body, f"map {path}")
|
||||
sys.stdout.buffer.write(body)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_ls(path: str) -> int:
|
||||
if not path.endswith("/"):
|
||||
path += "/"
|
||||
status, body = request("GET", vault_url(path))
|
||||
check(status, body, f"ls {path}")
|
||||
sys.stdout.buffer.write(body)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_search(query: list[str]) -> int:
|
||||
q = urllib.parse.quote_plus(" ".join(query))
|
||||
status, body = request("POST", f"{BASE}/search/simple/?query={q}")
|
||||
check(status, body, "search")
|
||||
sys.stdout.buffer.write(body)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_put(path: str, file_arg: str | None) -> int:
|
||||
data = read_body(file_arg)
|
||||
status, body = request("PUT", vault_url(path), data=data, headers={"Content-Type": "text/markdown"})
|
||||
check(status, body, f"put {path}")
|
||||
if VERIFY:
|
||||
status, body = request("GET", vault_url(path))
|
||||
if status != 200:
|
||||
raise EchoError(f"put {path}: write did not verify (GET returned {status})")
|
||||
print(f"ok: PUT {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_post(path: str, file_arg: str | None) -> int:
|
||||
data = read_body(file_arg)
|
||||
status, body = request("POST", vault_url(path), data=data, headers={"Content-Type": "text/markdown"})
|
||||
check(status, body, f"post {path}")
|
||||
print(f"ok: POST {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_append(path: str, line: str) -> int:
|
||||
status, body = request("GET", vault_url(path))
|
||||
if status == 200 and line.encode() in body:
|
||||
print(f"skip: line already present in {path}")
|
||||
return 0
|
||||
if status not in (200, 404):
|
||||
check(status, body, f"append(read) {path}")
|
||||
status, body = request("POST", vault_url(path), data=f"{line}\n".encode(), headers={"Content-Type": "text/markdown"})
|
||||
check(status, body, f"append {path}")
|
||||
print(f"ok: APPEND {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_patch(path: str, op: str, target_type: str, target: str, file_arg: str | None) -> int:
|
||||
if op not in {"append", "prepend", "replace"}:
|
||||
raise EchoError("patch: op must be append, prepend, or replace", 2)
|
||||
if target_type not in {"heading", "frontmatter", "block"}:
|
||||
raise EchoError("patch: target-type must be heading, frontmatter, or block", 2)
|
||||
data = normalize_patch_body(read_body(file_arg), op, target_type)
|
||||
status, body = request(
|
||||
"PATCH",
|
||||
vault_url(path),
|
||||
data=data,
|
||||
headers={
|
||||
"Operation": op,
|
||||
"Target-Type": target_type,
|
||||
"Target": target,
|
||||
"Content-Type": "application/json" if target_type == "frontmatter" else "text/markdown",
|
||||
},
|
||||
)
|
||||
check(status, body, f"patch {path} ({target})")
|
||||
print(f"ok: PATCH {op} {target_type} '{target}' -> {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_fm(path: str, field: str, value: str) -> int:
|
||||
return cmd_patch(path, "replace", "frontmatter", field, temp_file(normalize_json_scalar(value)))
|
||||
|
||||
|
||||
def temp_file(data: bytes) -> str:
|
||||
fh = tempfile.NamedTemporaryFile(delete=False)
|
||||
try:
|
||||
fh.write(data)
|
||||
fh.close()
|
||||
return fh.name
|
||||
finally:
|
||||
try:
|
||||
fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cmd_delete(path: str) -> int:
|
||||
status, body = request("DELETE", vault_url(path))
|
||||
check(status, body, f"delete {path}")
|
||||
print(f"ok: DELETE {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def parse_lock_time(value: str) -> int:
|
||||
try:
|
||||
return int(dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt.timezone.utc).timestamp())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_lock(owner: str) -> int:
|
||||
path = "_agent/locks/vault.lock"
|
||||
status, body = request("GET", vault_url(path))
|
||||
if status == 200 and body.strip():
|
||||
current = body.decode(errors="replace").strip()
|
||||
held_owner, _, held_iso = current.partition(" @ ")
|
||||
now_epoch = int(time.time())
|
||||
if held_owner != owner and now_epoch - parse_lock_time(held_iso) < LOCK_TTL:
|
||||
print(f"lock held by '{held_owner}' since {held_iso} (fresh)", file=sys.stderr)
|
||||
return 75
|
||||
status, body = request("PUT", vault_url(path), data=f"{owner} @ {now_iso()}\n".encode(), headers={"Content-Type": "text/markdown"})
|
||||
check(status, body, "lock")
|
||||
print(f"ok: locked by {owner}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_unlock(owner: str) -> int:
|
||||
path = "_agent/locks/vault.lock"
|
||||
status, body = request("GET", vault_url(path))
|
||||
if status == 200:
|
||||
held_owner = body.decode(errors="replace").split(" @ ", 1)[0]
|
||||
if held_owner != owner:
|
||||
print(f"lock owned by '{held_owner}', not '{owner}' - not releasing", file=sys.stderr)
|
||||
return 75
|
||||
status, body = request("DELETE", vault_url(path))
|
||||
if status != 404:
|
||||
check(status, body, "unlock")
|
||||
print("ok: unlocked")
|
||||
return 0
|
||||
|
||||
|
||||
def extract_heading(markdown: str, heading: str) -> str:
|
||||
lines = markdown.splitlines()
|
||||
out: list[str] = []
|
||||
capture = False
|
||||
marker = f"## {heading}"
|
||||
for line in lines:
|
||||
if line.strip() == marker:
|
||||
capture = True
|
||||
continue
|
||||
if capture and line.startswith("## "):
|
||||
break
|
||||
if capture:
|
||||
out.append(line)
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def cmd_scope(subcommand: str, text: str | None = None) -> int:
|
||||
path = "_agent/context/current-context.md"
|
||||
status, body = request("GET", vault_url(path))
|
||||
check(status, body, f"scope {subcommand}")
|
||||
current = body.decode(errors="replace")
|
||||
if subcommand == "show":
|
||||
scope = extract_heading(current, "Scope")
|
||||
print("-- Active scope --")
|
||||
print(scope)
|
||||
scope_updated = ""
|
||||
for line in current.splitlines():
|
||||
if line.startswith("scope_updated:"):
|
||||
scope_updated = line.split(":", 1)[1].strip().strip('"')
|
||||
break
|
||||
print(f"scope_updated: {scope_updated or '<missing - drift cannot be detected; run scope set or repair>'}")
|
||||
return 0
|
||||
if subcommand != "set":
|
||||
raise EchoError("scope: use show or set", 2)
|
||||
if not text:
|
||||
raise EchoError("scope set needs the new scope text", 2)
|
||||
prior = extract_heading(current, "Scope").replace("\n", " ").strip()[:140] or "(prior scope)"
|
||||
history_line = f"- {today()}: {prior}\n"
|
||||
cmd_patch(path, "prepend", "heading", "Current Context::Scope History", temp_file(history_line.encode()))
|
||||
cmd_patch(path, "replace", "heading", "Current Context::Scope", temp_file(f"{text}\n".encode()))
|
||||
cmd_patch(path, "replace", "frontmatter", "scope_updated", temp_file(json.dumps(today()).encode()))
|
||||
print(f"ok: scope switched (prior archived to Scope History; scope_updated={today()})")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Validated ECHO vault client")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("get").add_argument("path")
|
||||
sub.add_parser("map").add_argument("path")
|
||||
sub.add_parser("ls").add_argument("path")
|
||||
sub.add_parser("search").add_argument("query", nargs="+")
|
||||
p = sub.add_parser("put"); p.add_argument("path"); p.add_argument("file", nargs="?")
|
||||
p = sub.add_parser("post"); p.add_argument("path"); p.add_argument("file", nargs="?")
|
||||
p = sub.add_parser("append"); p.add_argument("path"); p.add_argument("line")
|
||||
p = sub.add_parser("patch"); p.add_argument("path"); p.add_argument("op"); p.add_argument("target_type"); p.add_argument("target"); p.add_argument("file", nargs="?")
|
||||
p = sub.add_parser("fm"); p.add_argument("path"); p.add_argument("field"); p.add_argument("value")
|
||||
p = sub.add_parser("bump"); p.add_argument("path"); p.add_argument("date", nargs="?")
|
||||
sub.add_parser("delete").add_argument("path")
|
||||
sub.add_parser("lock").add_argument("owner")
|
||||
sub.add_parser("unlock").add_argument("owner")
|
||||
p = sub.add_parser("scope"); p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"]); p.add_argument("text", nargs="?")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
if args.cmd == "get":
|
||||
return cmd_get(args.path)
|
||||
if args.cmd == "map":
|
||||
return cmd_map(args.path)
|
||||
if args.cmd == "ls":
|
||||
return cmd_ls(args.path)
|
||||
if args.cmd == "search":
|
||||
return cmd_search(args.query)
|
||||
if args.cmd == "put":
|
||||
return cmd_put(args.path, args.file)
|
||||
if args.cmd == "post":
|
||||
return cmd_post(args.path, args.file)
|
||||
if args.cmd == "append":
|
||||
return cmd_append(args.path, args.line)
|
||||
if args.cmd == "patch":
|
||||
return cmd_patch(args.path, args.op, args.target_type, args.target, args.file)
|
||||
if args.cmd == "fm":
|
||||
return cmd_fm(args.path, args.field, args.value)
|
||||
if args.cmd == "bump":
|
||||
return cmd_fm(args.path, "updated", json.dumps(args.date or today()))
|
||||
if args.cmd == "delete":
|
||||
return cmd_delete(args.path)
|
||||
if args.cmd == "lock":
|
||||
return cmd_lock(args.owner)
|
||||
if args.cmd == "unlock":
|
||||
return cmd_unlock(args.owner)
|
||||
if args.cmd == "scope":
|
||||
return cmd_scope(args.subcommand, args.text)
|
||||
except EchoError as exc:
|
||||
print(f"echo.py: {exc}", file=sys.stderr)
|
||||
return exc.code
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,125 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dry-run or apply ECHO vault schema migrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import echo # noqa: E402
|
||||
|
||||
|
||||
CURRENT_SCHEMA = 2
|
||||
|
||||
|
||||
def get_text(path: str) -> str | None:
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 404:
|
||||
return None
|
||||
echo.check(status, body, f"get {path}")
|
||||
return body.decode(errors="replace")
|
||||
|
||||
|
||||
def list_files(path: str) -> list[str]:
|
||||
if not path.endswith("/"):
|
||||
path += "/"
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 404:
|
||||
return []
|
||||
echo.check(status, body, f"ls {path}")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return [entry for entry in payload.get("files", []) if not entry.endswith("/")]
|
||||
|
||||
|
||||
def put_text(path: str, text: str) -> None:
|
||||
status, body = echo.request("PUT", echo.vault_url(path), data=text.encode(), headers={"Content-Type": "text/markdown"})
|
||||
echo.check(status, body, f"put {path}")
|
||||
|
||||
|
||||
def delete(path: str) -> None:
|
||||
status, body = echo.request("DELETE", echo.vault_url(path))
|
||||
if status != 404:
|
||||
echo.check(status, body, f"delete {path}")
|
||||
|
||||
|
||||
def move(src: str, dst: str) -> None:
|
||||
text = get_text(src)
|
||||
if text is None:
|
||||
return
|
||||
put_text(dst, text)
|
||||
delete(src)
|
||||
|
||||
|
||||
def do_or_show(apply: bool, desc: str, func=None) -> None:
|
||||
if apply:
|
||||
print(f"migrate: APPLY {desc}")
|
||||
if func:
|
||||
func()
|
||||
else:
|
||||
print(f"migrate: PLAN {desc}")
|
||||
|
||||
|
||||
def marker_schema() -> int:
|
||||
marker = get_text("_agent/echo-vault.md")
|
||||
if marker is None:
|
||||
print("migrate: marker missing - vault not bootstrapped. Run bootstrap.py, not migrate.py.")
|
||||
raise SystemExit(3)
|
||||
for line in marker.splitlines():
|
||||
if line.startswith("schema_version:"):
|
||||
try:
|
||||
return int(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate ECHO vault schema")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
start = marker_schema()
|
||||
print(f"migrate: vault schema_version={start}, plugin schema={CURRENT_SCHEMA} {'(APPLY)' if args.apply else '(dry-run)'}")
|
||||
if start >= CURRENT_SCHEMA:
|
||||
print("migrate: up to date - nothing to do.")
|
||||
return 0
|
||||
|
||||
if start < 1:
|
||||
print("migrate: [0->1] retire in-vault control docs")
|
||||
for filename in ["CLAUDE.md", "BOOTSTRAP.md", "STRUCTURE.md", "index.md"]:
|
||||
if get_text(filename) is not None:
|
||||
do_or_show(args.apply, f"delete vault/{filename} after external backup", lambda f=filename: delete(f))
|
||||
print("migrate: [0->1] reminder: scrub dangling control-doc links from Related sections.")
|
||||
|
||||
if start < 2:
|
||||
print("migrate: [1->2] fold reviews/ into journal/ and _agent/health/")
|
||||
for filename in list_files("reviews/weekly"):
|
||||
if filename.endswith(".md"):
|
||||
dst = f"journal/weekly/{filename.replace('-review.md', '.md')}"
|
||||
do_or_show(args.apply, f"move reviews/weekly/{filename} -> {dst}", lambda f=filename, d=dst: move(f"reviews/weekly/{f}", d))
|
||||
for filename in list_files("reviews/monthly"):
|
||||
if filename.endswith(".md"):
|
||||
dst = f"_agent/health/{filename}" if filename.endswith("vault-health.md") else f"journal/monthly/{filename}"
|
||||
do_or_show(args.apply, f"move reviews/monthly/{filename} -> {dst}", lambda f=filename, d=dst: move(f"reviews/monthly/{f}", d))
|
||||
for period in ["quarterly", "annual"]:
|
||||
for filename in list_files(f"reviews/{period}"):
|
||||
if filename.endswith(".md"):
|
||||
dst = f"journal/{period}/{filename}"
|
||||
do_or_show(args.apply, f"move reviews/{period}/{filename} -> {dst}", lambda p=period, f=filename, d=dst: move(f"reviews/{p}/{f}", d))
|
||||
print("migrate: [1->2] reminder: update inbound reviews wikilinks manually.")
|
||||
|
||||
do_or_show(args.apply, f"set _agent/echo-vault.md schema_version -> {CURRENT_SCHEMA}", lambda: echo.cmd_fm("_agent/echo-vault.md", "schema_version", str(CURRENT_SCHEMA)))
|
||||
print("migrate: migration complete." if args.apply else "migrate: dry-run only. Re-run with --apply to perform changes.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"$comment": "CANONICAL machine-readable routing manifest for the ECHO vault. This is the single source of truth for 'what paths may be written to'. The human-readable tables in SKILL.md, references/routing-map.md, and references/api-reference.md are DERIVED views of this file — when they disagree, this file wins. vault_lint.py consumes it to enforce the core rule: if a path matches no route here (and is not a retired path), nothing should be written to it. Patterns are Python regexes matched against vault-root-relative paths (no leading slash, no /vault/ prefix).",
|
||||
"schema_version": 2,
|
||||
"routes": [
|
||||
{ "id": "inbox-captures", "pattern": "^inbox/captures/inbox\\.md$", "method": "POST", "trigger": "Destination unknown at capture time", "distinct_because": "Only path whose contract is deferred routing" },
|
||||
{ "id": "inbox-imports", "pattern": "^inbox/imports/[^/]+\\.md$", "method": "PUT", "trigger": "Raw external material dropped wholesale", "distinct_because": "Bulk un-triaged material vs single-line captures" },
|
||||
{ "id": "inbox-processing-log", "pattern": "^inbox/processing-log/\\d{4}-\\d{2}-\\d{2}\\.md$", "method": "POST", "trigger": "An inbox item is routed to its real home", "distinct_because": "Audit trail of moves, not memory itself" },
|
||||
|
||||
{ "id": "journal-daily", "pattern": "^journal/daily/\\d{4}-\\d{2}-\\d{2}\\.md$", "method": "PATCH", "trigger": "First agent activity on a given day", "distinct_because": "Finest grain; PATCHed repeatedly within its period" },
|
||||
{ "id": "journal-weekly", "pattern": "^journal/weekly/\\d{4}-W\\d{2}\\.md$", "method": "PUT", "trigger": "First substantive session of a new ISO week (opt-in)", "distinct_because": "ISO-week grain" },
|
||||
{ "id": "journal-monthly", "pattern": "^journal/monthly/\\d{4}-\\d{2}\\.md$", "method": "PUT", "trigger": "First substantive session of a new month", "distinct_because": "Month grain" },
|
||||
{ "id": "journal-quarterly", "pattern": "^journal/quarterly/\\d{4}-Q[1-4]\\.md$", "method": "PUT", "trigger": "Manual / on request only", "distinct_because": "Strategic grain; never auto-fires" },
|
||||
{ "id": "journal-annual", "pattern": "^journal/annual/\\d{4}\\.md$", "method": "PUT", "trigger": "Manual / on request only", "distinct_because": "Coarsest grain; never auto-fires" },
|
||||
{ "id": "journal-templates", "pattern": "^journal/templates/.+\\.md$", "method": "PUT", "trigger": "Bootstrap seed only", "distinct_because": "Holds templates, not journal content" },
|
||||
|
||||
{ "id": "projects-active", "pattern": "^projects/active/[^/]+\\.md$", "method": "PUT", "trigger": "Work in motion now", "distinct_because": "Default state for anything being worked", "status": "active" },
|
||||
{ "id": "projects-incubating", "pattern": "^projects/incubating/[^/]+\\.md$", "method": "PUT", "trigger": "Idea captured, work not started", "distinct_because": "Pre-work", "status": "incubating" },
|
||||
{ "id": "projects-on-hold", "pattern": "^projects/on-hold/[^/]+\\.md$", "method": "PUT", "trigger": "Paused but still tracked", "distinct_because": "Resumable; not terminal", "status": "on-hold" },
|
||||
{ "id": "projects-archived", "pattern": "^projects/archived/[^/]+\\.md$", "method": "PUT", "trigger": "Done, abandoned, or rolled up", "distinct_because": "Terminal; kept for history", "status": "archived" },
|
||||
{ "id": "projects-template", "pattern": "^projects/project-template\\.md$", "method": "PUT", "trigger": "Bootstrap seed only", "distinct_because": "Template, not a project" },
|
||||
|
||||
{ "id": "areas", "pattern": "^areas/(business|personal|learning|systems)/[^/]+\\.md$", "method": "PUT", "trigger": "Ongoing domain with no finish line", "distinct_because": "No end state — disqualifies it from projects/" },
|
||||
|
||||
{ "id": "resources-people", "pattern": "^resources/people/[^/]+\\.md$", "method": "PUT", "trigger": "A fact about a specific person", "distinct_because": "Keyed to a person" },
|
||||
{ "id": "resources-companies", "pattern": "^resources/companies/[^/]+\\.md$", "method": "PUT", "trigger": "A fact about an organization", "distinct_because": "Keyed to an organization, not an individual" },
|
||||
{ "id": "resources-concepts", "pattern": "^resources/concepts/[^/]+\\.md$", "method": "PUT", "trigger": "A reusable concept/idea", "distinct_because": "An idea vs an external source" },
|
||||
{ "id": "resources-references", "pattern": "^resources/references/[^/]+\\.md$", "method": "PUT", "trigger": "An external source/link worth keeping", "distinct_because": "Points outward" },
|
||||
{ "id": "resources-meetings", "pattern": "^resources/meetings/\\d{4}-\\d{2}-\\d{2}-[^/]+\\.md$", "method": "PUT", "trigger": "Notes tied to a specific meeting", "distinct_because": "Event-anchored to a meeting" },
|
||||
|
||||
{ "id": "decisions-by-date", "pattern": "^decisions/by-date/\\d{4}-\\d{2}-\\d{2}-[^/]+\\.md$", "method": "PUT", "trigger": "A non-obvious decision worth recording", "distinct_because": "Chronological system of record for decisions" },
|
||||
{ "id": "decisions-template", "pattern": "^decisions/decision-template\\.md$", "method": "PUT", "trigger": "Bootstrap seed only", "distinct_because": "Template, not a decision" },
|
||||
|
||||
{ "id": "agent-marker", "pattern": "^_agent/echo-vault\\.md$", "method": "PUT", "trigger": "Bootstrap / schema migration only", "distinct_because": "Plugin-owned probe; never hand-edited" },
|
||||
{ "id": "agent-context", "pattern": "^_agent/context/[^/]+\\.md$", "method": "PATCH", "trigger": "Active scope changes / task bundles", "distinct_because": "Single live scope pointer + bundles" },
|
||||
{ "id": "agent-semantic", "pattern": "^_agent/memory/semantic/[^/]+\\.md$", "method": "PUT", "trigger": "A durable fact/pattern (incl. operator-preferences.md)", "distinct_because": "Timeless fact" },
|
||||
{ "id": "agent-episodic", "pattern": "^_agent/memory/episodic/[^/]+\\.md$", "method": "PUT", "trigger": "A record of what happened, when", "distinct_because": "Anchored to an event in time" },
|
||||
{ "id": "agent-working", "pattern": "^_agent/memory/working/[^/]+\\.md$", "method": "PUT", "trigger": "Short-lived state for the current effort", "distinct_because": "Explicitly transient" },
|
||||
{ "id": "agent-sessions", "pattern": "^_agent/sessions/\\d{4}-\\d{2}-\\d{2}(-\\d{4})?-[^/]+\\.md$", "method": "PUT", "trigger": "A substantive session ends", "distinct_because": "Per-session record (new ones require HHMM)" },
|
||||
{ "id": "agent-health", "pattern": "^_agent/health/\\d{4}-\\d{2}-vault-health\\.md$", "method": "PUT", "trigger": "First substantive session of a month", "distinct_because": "Vault integrity, not work narrative" },
|
||||
{ "id": "agent-heartbeat", "pattern": "^_agent/heartbeat/[^/]+\\.md$", "method": "PUT", "trigger": "End of every session", "distinct_because": "O(1) orientation pointer; overwritten, never grows" },
|
||||
{ "id": "agent-templates", "pattern": "^_agent/templates/.+\\.md$", "method": "PUT", "trigger": "Bootstrap seed only", "distinct_because": "Holds templates, not memory" },
|
||||
{ "id": "agent-skills-active", "pattern": "^_agent/skills/active/[^/]+\\.md$", "method": "PUT", "trigger": "A skill/plugin catalogued as a capability", "distinct_because": "Catalogs a capability vs the build effort" },
|
||||
{ "id": "agent-skills-archived","pattern": "^_agent/skills/archived/[^/]+\\.md$", "method": "PUT", "trigger": "A catalogued skill is retired", "distinct_because": "Terminal state of the skill catalog" },
|
||||
{ "id": "agent-locks", "pattern": "^_agent/locks/[^/]+\\.lock$", "method": "PUT", "trigger": "Advisory multi-writer lock acquire/release", "distinct_because": "Concurrency coordination, not memory" },
|
||||
|
||||
{ "id": "leaf-readme", "pattern": "^(.+/)?README\\.md$", "method": "PUT", "trigger": "Bootstrap leaf signpost / vault root README", "distinct_because": "Human signpost, not read for routing" }
|
||||
],
|
||||
"retired": [
|
||||
{ "pattern": "^reviews/", "retired_in_schema": 2, "replacement": "journal/{weekly,monthly,quarterly,annual}/ and _agent/health/" },
|
||||
{ "pattern": "^decisions/by-project/", "retired_in_schema": 1, "replacement": "[[wikilink]] under the project's ## Key Decisions" },
|
||||
{ "pattern": "^archive/", "retired_in_schema": 0, "replacement": "projects/archived/ and _agent/skills/archived/" },
|
||||
{ "pattern": "^(CLAUDE|BOOTSTRAP|STRUCTURE|index)\\.md$", "retired_in_schema": 1, "replacement": "All control logic lives in the plugin references/, not the vault" }
|
||||
]
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for closeout.py helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
|
||||
import closeout
|
||||
|
||||
|
||||
def test_slugify() -> None:
|
||||
assert closeout.slugify("Codex Plugin Closeout Fix!") == "codex-plugin-closeout-fix"
|
||||
|
||||
|
||||
def test_session_log_render_uses_codex_client_and_related_links() -> None:
|
||||
args = argparse.Namespace(
|
||||
slug="codex-plugin-closeout-fix",
|
||||
goal="Implement closeout helper.",
|
||||
actions="Added closeout.py.",
|
||||
next_step="Start a new thread to load the patched plugin.",
|
||||
daily_line="Implemented closeout helper.",
|
||||
related=["[[_agent/skills/active/echo-memory-codex-plugin]]"],
|
||||
notes_read=[],
|
||||
outputs=["`closeout.py` - session closeout helper"],
|
||||
decisions=[],
|
||||
open_threads=[],
|
||||
)
|
||||
body = closeout.render_session_log(
|
||||
args,
|
||||
dt.datetime(2026, 6, 20, 5, 45),
|
||||
"_agent/sessions/2026-06-20-0545-codex-plugin-closeout-fix.md",
|
||||
)
|
||||
assert "client: codex" in body
|
||||
assert "[[_agent/skills/active/echo-memory-codex-plugin]]" in body
|
||||
assert "Session path:" in body
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_slugify()
|
||||
test_session_log_render_uses_codex_client_and_related_links()
|
||||
print("test_closeout: ok")
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for echo.py client-side normalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import echo
|
||||
|
||||
|
||||
def test_heading_replace_body_is_newline_guarded() -> None:
|
||||
assert (
|
||||
echo.normalize_patch_body(b"- [x] Formalized\n", "replace", "heading")
|
||||
== b"\n- [x] Formalized\n"
|
||||
)
|
||||
|
||||
|
||||
def test_heading_append_body_ends_with_newline() -> None:
|
||||
assert echo.normalize_patch_body(b"- item", "append", "heading") == b"- item\n"
|
||||
|
||||
|
||||
def test_frontmatter_plain_string_becomes_json_scalar() -> None:
|
||||
assert json.loads(echo.normalize_json_scalar("Fabrication Lead")) == "Fabrication Lead"
|
||||
|
||||
|
||||
def test_frontmatter_existing_json_is_preserved() -> None:
|
||||
assert echo.normalize_json_scalar('"2026-06-20"') == b'"2026-06-20"'
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_heading_replace_body_is_newline_guarded()
|
||||
test_heading_append_body_ends_with_newline()
|
||||
test_frontmatter_plain_string_becomes_json_scalar()
|
||||
test_frontmatter_existing_json_is_preserved()
|
||||
print("test_echo_client: ok")
|
||||
@@ -1,244 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only ECHO vault invariant checker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import echo # noqa: E402
|
||||
|
||||
|
||||
TODAY = dt.date.fromisoformat(os.environ.get("ECHO_TODAY") or dt.date.today().isoformat())
|
||||
STALE_DAYS = int(os.environ.get("STALE_DAYS", "30"))
|
||||
INBOX_DAYS = int(os.environ.get("INBOX_DAYS", "14"))
|
||||
SCOPE_STALE_SESSIONS = int(os.environ.get("SCOPE_STALE_SESSIONS", "3"))
|
||||
LIFECYCLES = ["active", "incubating", "on-hold", "archived"]
|
||||
SKIP = {"README.md", "project-template.md", "decision-template.md"}
|
||||
|
||||
violations: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
def flag(check: str, message: str) -> None:
|
||||
violations.append((check, message))
|
||||
|
||||
|
||||
def get(path: str) -> str | None:
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 404:
|
||||
return None
|
||||
echo.check(status, body, f"get {path}")
|
||||
return body.decode(errors="replace")
|
||||
|
||||
|
||||
def list_dir(path: str) -> tuple[list[str], list[str]]:
|
||||
if path and not path.endswith("/"):
|
||||
path += "/"
|
||||
body = get(path)
|
||||
if body is None:
|
||||
return [], []
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return [], []
|
||||
entries = list(payload.get("files", [])) + list(payload.get("folders", []))
|
||||
files = [entry for entry in entries if not entry.endswith("/")]
|
||||
folders = [entry[:-1] for entry in entries if entry.endswith("/")]
|
||||
return files, folders
|
||||
|
||||
|
||||
def walk(prefix: str = ""):
|
||||
files, folders = list_dir(prefix)
|
||||
for filename in files:
|
||||
yield prefix + filename
|
||||
for folder in folders:
|
||||
yield from walk(f"{prefix}{folder}/")
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[str, str]:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return "", text
|
||||
for index in range(1, len(lines)):
|
||||
if lines[index].strip() == "---":
|
||||
return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :])
|
||||
return "", text
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[str, dict[str, object]]:
|
||||
raw, _ = split_frontmatter(text)
|
||||
fields: dict[str, object] = {}
|
||||
for line in raw.splitlines():
|
||||
match = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
||||
if not match:
|
||||
continue
|
||||
value: object = match.group(2).strip().strip('"').strip("'")
|
||||
if isinstance(value, str) and value.startswith("[") and value.endswith("]"):
|
||||
value = [part.strip().strip('"').strip("'") for part in value[1:-1].split(",") if part.strip()]
|
||||
fields[match.group(1)] = value
|
||||
return raw, fields
|
||||
|
||||
|
||||
def parse_date(value: object) -> dt.date | None:
|
||||
match = re.match(r"(\d{4}-\d{2}-\d{2})", str(value or ""))
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return dt.date.fromisoformat(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def as_list(value: object) -> list[object]:
|
||||
if value in (None, ""):
|
||||
return []
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
|
||||
def route_matchers() -> tuple[list[tuple[str, re.Pattern[str]]], list[tuple[re.Pattern[str], str]]]:
|
||||
routing = json.loads((SCRIPT_DIR / "routing.json").read_text(encoding="utf-8"))
|
||||
routes = [(item["id"], re.compile(item["pattern"])) for item in routing.get("routes", [])]
|
||||
retired = [(re.compile(item["pattern"]), item.get("replacement", "")) for item in routing.get("retired", [])]
|
||||
return routes, retired
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
if get("_agent/echo-vault.md") is None:
|
||||
print("vault-lint: marker missing - vault not bootstrapped (run bootstrap.py).", file=sys.stderr)
|
||||
return 3
|
||||
except Exception as exc:
|
||||
print(f"vault-lint: vault unreachable ({exc}).", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
routes, retired = route_matchers()
|
||||
except Exception as exc:
|
||||
routes, retired = [], []
|
||||
flag("routing-manifest", f"could not load routing.json ({exc}) - path checks skipped")
|
||||
|
||||
all_files = list(walk())
|
||||
|
||||
for path in all_files:
|
||||
if routes and not any(rx.match(path) for _, rx in routes):
|
||||
replacement = next((repl for rx, repl in retired if rx.match(path)), None)
|
||||
if replacement is not None:
|
||||
flag("retired-path", f"{path}: retired location - should be {replacement}")
|
||||
else:
|
||||
flag("unknown-path", f"{path}: matches no route in routing.json")
|
||||
|
||||
template_re = re.compile(r"(^|/)(templates/|.*-template\.md$)")
|
||||
for path in all_files:
|
||||
base = path.rsplit("/", 1)[-1]
|
||||
if base in SKIP or template_re.search(path) or not path.endswith(".md"):
|
||||
continue
|
||||
text = get(path) or ""
|
||||
raw, fm = parse_frontmatter(text)
|
||||
if "[[" in raw:
|
||||
flag("frontmatter-wikilink", f"{path}: '[[...]]' inside frontmatter")
|
||||
for key in ["type", "created"]:
|
||||
if fm and not str(fm.get(key, "")).strip():
|
||||
flag("missing-frontmatter", f"{path}: missing {key}")
|
||||
created, updated = parse_date(fm.get("created")), parse_date(fm.get("updated"))
|
||||
if created and updated and updated < created:
|
||||
flag("date-order", f"{path}: updated {updated} is before created {created}")
|
||||
if updated and updated > TODAY:
|
||||
flag("future-date", f"{path}: updated {updated} is in the future (today {TODAY})")
|
||||
for source_note in as_list(fm.get("source_notes")):
|
||||
if "[[" in str(source_note):
|
||||
flag("source-notes-wikilink", f"{path}: source_notes contains wikilink '{source_note}'")
|
||||
|
||||
slug_homes: dict[str, list[str]] = {}
|
||||
for lifecycle in LIFECYCLES:
|
||||
files, _ = list_dir(f"projects/{lifecycle}")
|
||||
for filename in files:
|
||||
if filename in SKIP or not filename.endswith(".md"):
|
||||
continue
|
||||
slug = filename[:-3]
|
||||
slug_homes.setdefault(slug, []).append(lifecycle)
|
||||
text = get(f"projects/{lifecycle}/{filename}") or ""
|
||||
_, fm = parse_frontmatter(text)
|
||||
status = str(fm.get("status", "")).strip()
|
||||
if status and status != lifecycle:
|
||||
flag("folder/status", f"projects/{lifecycle}/{filename}: status='{status}' but folder='{lifecycle}'")
|
||||
if lifecycle == "active":
|
||||
updated = parse_date(fm.get("updated"))
|
||||
if updated and (TODAY - updated).days > STALE_DAYS:
|
||||
flag("stale-active", f"projects/active/{filename}: updated {updated} ({(TODAY - updated).days}d ago)")
|
||||
for slug, homes in slug_homes.items():
|
||||
if len(homes) > 1:
|
||||
flag("duplicate-slug", f"'{slug}' exists in {', '.join(homes)}")
|
||||
|
||||
for path in all_files:
|
||||
if re.match(r"^journal/daily/.*\.md$", path):
|
||||
text = get(path) or ""
|
||||
count = len(re.findall(r"(?m)^## Agent Log\s*$", text))
|
||||
if count > 1:
|
||||
flag("duplicate-agent-log", f"{path}: {count} '## Agent Log' headings")
|
||||
|
||||
inbox = get("inbox/captures/inbox.md") or ""
|
||||
for line in inbox.splitlines():
|
||||
match = re.match(r"^\s*-\s*(\d{4}-\d{2}-\d{2})\b", line)
|
||||
date = parse_date(match.group(1)) if match else None
|
||||
if date and (TODAY - date).days > INBOX_DAYS:
|
||||
flag("aging-inbox", f"inbox capture {date} ({(TODAY - date).days}d): {line.strip()[:80]}")
|
||||
|
||||
context = get("_agent/context/current-context.md")
|
||||
if context is not None:
|
||||
_, fm = parse_frontmatter(context)
|
||||
scope_updated = parse_date(fm.get("scope_updated"))
|
||||
if scope_updated is None:
|
||||
flag("scope-no-timestamp", "_agent/context/current-context.md: no scope_updated frontmatter")
|
||||
else:
|
||||
since = [
|
||||
path
|
||||
for path in all_files
|
||||
if (match := re.match(r"^_agent/sessions/(\d{4}-\d{2}-\d{2})", path))
|
||||
and (date := parse_date(match.group(1)))
|
||||
and date > scope_updated
|
||||
]
|
||||
if len(since) >= SCOPE_STALE_SESSIONS:
|
||||
flag("scope-stale", f"scope set {scope_updated}; {len(since)} sessions logged since without a switch")
|
||||
|
||||
if not violations:
|
||||
print("vault-lint: clean - all invariants hold.")
|
||||
return 0
|
||||
|
||||
print(f"vault-lint: {len(violations)} violation(s) found\n")
|
||||
labels = {
|
||||
"folder/status": "Folder <-> status mismatch",
|
||||
"duplicate-slug": "Duplicate slug across lifecycle folders",
|
||||
"frontmatter-wikilink": "Wikilink in frontmatter",
|
||||
"duplicate-agent-log": "Duplicate Agent Log heading",
|
||||
"stale-active": f"Stale active project (> {STALE_DAYS}d)",
|
||||
"aging-inbox": f"Inbox capture aging (> {INBOX_DAYS}d)",
|
||||
"unknown-path": "Path matches no route",
|
||||
"retired-path": "Retired path",
|
||||
"missing-frontmatter": "Missing required frontmatter",
|
||||
"date-order": "updated earlier than created",
|
||||
"future-date": "updated date is in the future",
|
||||
"source-notes-wikilink": "Wikilink in source_notes",
|
||||
"routing-manifest": "routing.json problem",
|
||||
"scope-no-timestamp": "Scope has no timestamp",
|
||||
"scope-stale": f"Scope may have drifted (>= {SCOPE_STALE_SESSIONS} sessions)",
|
||||
}
|
||||
grouped: dict[str, list[str]] = {}
|
||||
for check, message in violations:
|
||||
grouped.setdefault(check, []).append(message)
|
||||
for check, messages in grouped.items():
|
||||
print(f"## {labels.get(check, check)}")
|
||||
for message in messages:
|
||||
print(f" - {message}")
|
||||
print()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user