Skip to content

Commit e4540c3

Browse files
bb-connorclaude
andcommitted
Two more seeded episodes, sdk-conformance spec, kb-staleness-badge plugin
(a) Two seeded episodes matching the post-`make kb-migrate-seeds` shape: vault/episodes/capability-revocation-architecture.md - referenced by spec/capability-revocation.md - typed authoritative_files + tests, summary as opening prose, constraints as `## Constraints` section. vault/episodes/guard-policy-fail-closed.md - referenced by BOTH spec/guard-pipeline.md AND spec/policy-compiler.md (shared lineage — the fail-closed obligation runs across both). - type: episode-workflow-constraint (from the seed's type: workflow_constraint). Plus a third (mcp-sdk-conformance.md) for completeness — referenced by the new spec below. (b) vault/spec/sdk-conformance.md — the fifth worked spec, completing the Capability/Receipt/Guard/Policy/Standard quintet of chio-node labels. chio-node: Standard. Five normative claims (verdict-matrix as the only canonical source; transport-roundtrip + behavioral coverage both required; cross-language harness identical; protocol changes update verdict-matrix in same PR; conformance failures must receipt). Cites real arc paths from chio-mcp-adapter/, chio-mcp-edge/, chio-conformance/verdict_matrix/, tests/conformance/peers/{python,js}/. Lineage from episode.mcp-sdk-conformance. (d) Second custom Obsidian plugin: kb-staleness-badge. - Reads `last-validated:` from the active note's frontmatter. - Renders a colored pill (green/yellow/orange/red) in the title bar based on day-age. Hover for the threshold reminder. - Thresholds (30/90/180) match stale-specs.md, the morning brief, and release-qualification's release-blocker rule. - Works in real time: re-renders on active-leaf-change and on metadataCache changes to the active file. - Read-only. Does NOT bump last-validated automatically — that's a deliberate human action. - File layout mirrors episode-promoter exactly (manifest.json, package.json, tsconfig, esbuild.config.mjs, src/main.ts, versions.json, version-bump.mjs, README.md, .gitignore). - Added to .obsidian/community-plugins.json (now 10 plugins) and un-ignored in .gitignore (manifest.json + versions.json tracked). (c) PR #6 merge + delete-branch in a follow-up after this push. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2ac83e5 commit e4540c3

15 files changed

Lines changed: 565 additions & 1 deletion

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ kb-engine/uv.lock
4848
.obsidian/plugins/*/manifest.json
4949
!.obsidian/plugins/episode-promoter/manifest.json
5050
!.obsidian/plugins/episode-promoter/versions.json
51+
!.obsidian/plugins/kb-staleness-badge/manifest.json
52+
!.obsidian/plugins/kb-staleness-badge/versions.json
5153

5254
# OS / Editor
5355
.DS_Store

.obsidian/community-plugins.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
"periodic-notes",
88
"obsidian-icon-folder",
99
"obsidian-style-settings",
10-
"episode-promoter"
10+
"episode-promoter",
11+
"kb-staleness-badge"
1112
]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Built artifact — produced by `npm run build`
2+
main.js
3+
main.js.map
4+
5+
# npm
6+
node_modules/
7+
package-lock.json
8+
9+
# Per-vault user settings
10+
data.json
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# KB Staleness Badge
2+
3+
Custom Obsidian plugin for chio-developer-base. Shows a colored badge in the active note's title bar based on the `last-validated:` frontmatter field.
4+
5+
## What it does
6+
7+
- Reads `last-validated:` from the active note's frontmatter (parsed as YYYY-MM-DD).
8+
- Computes age in days against today's date.
9+
- Renders a small colored pill in the view-header title container:
10+
11+
| Age (days) | Color | Meaning |
12+
| ---------- | ----- | ------- |
13+
| `< 30` | green | fresh |
14+
| `30–89` | yellow | gentle nudge — daily-note morning brief flags at 60d |
15+
| `90–179` | orange | stale-specs query flags it |
16+
| `≥ 180` | red | release blocker per [release-qualification playbook](../../../vault/playbooks/release-qualification.md) |
17+
18+
Hover the badge for the exact day-count and threshold reminder.
19+
20+
## What it does NOT do
21+
22+
- It does not modify the note. Read-only.
23+
- It does not write to Graphiti. Per [AGENTS.md](../../../AGENTS.md) hard rule #1, only the vault-sync daemon is permitted to write Graphiti.
24+
- It does not auto-bump `last-validated:`. That's a deliberate human action — bumping the date asserts "I re-read this against current code."
25+
- It does not parse non-YYYY-MM-DD date formats. The plugin is best-effort; ambiguous dates get no badge.
26+
27+
## Develop
28+
29+
```sh
30+
cd .obsidian/plugins/kb-staleness-badge
31+
npm install
32+
npm run dev # watches src/main.ts → main.js
33+
```
34+
35+
Production build:
36+
37+
```sh
38+
npm run build
39+
```
40+
41+
The compiled `main.js` is **not committed** (see this directory's `.gitignore`). Obsidian users build it themselves on first install.
42+
43+
## File layout
44+
45+
```
46+
kb-staleness-badge/
47+
├── manifest.json Obsidian plugin metadata (committed)
48+
├── package.json npm config (committed)
49+
├── tsconfig.json TypeScript config (committed)
50+
├── esbuild.config.mjs build script (committed)
51+
├── src/main.ts plugin source (committed)
52+
├── README.md this file (committed)
53+
├── versions.json plugin version → minAppVersion map (committed)
54+
├── version-bump.mjs npm version helper (committed)
55+
├── .gitignore (committed)
56+
├── main.js built artifact (gitignored)
57+
├── node_modules/ (gitignored)
58+
└── data.json per-vault settings (gitignored — user state)
59+
```
60+
61+
## Settings (Phase 3+)
62+
63+
Today the thresholds are hard-coded as `DEFAULT_SETTINGS`:
64+
65+
```ts
66+
yellowDays: 30,
67+
orangeDays: 90,
68+
redDays: 180,
69+
```
70+
71+
A future settings tab will let users tune these per-vault. For now, edit `src/main.ts` and rebuild.
72+
73+
## Phase awareness
74+
75+
This is a **Phase 3** deliverable per [PLAN.md](../../../PLAN.md). Listed in [`.obsidian/community-plugins.json`](../../community-plugins.json) so Obsidian recognizes it; will fail to load until `main.js` is built.
76+
77+
## See also
78+
79+
- [AGENTS.md](../../../AGENTS.md)
80+
- [PLAN.md](../../../PLAN.md)
81+
- [`vault/_meta/queries/stale-specs.md`](../../../vault/_meta/queries/stale-specs.md) — the org-wide staleness dashboard this badge complements
82+
- [`vault/playbooks/release-qualification.md`](../../../vault/playbooks/release-qualification.md) — the release blocker rule (≥180 days)
83+
- [`vault/_meta/templates/spec.md`](../../../vault/_meta/templates/spec.md) — the spec template that pre-fills `last-validated:`
84+
- [`episode-promoter/`](../episode-promoter/) — sibling custom plugin
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import esbuild from "esbuild";
2+
import process from "process";
3+
import builtins from "builtin-modules";
4+
5+
const banner = `/* KB Staleness Badge — built from src/main.ts. Do not edit main.js by hand. */`;
6+
7+
const prod = process.argv[2] === "production";
8+
9+
const ctx = await esbuild.context({
10+
banner: { js: banner },
11+
entryPoints: ["src/main.ts"],
12+
bundle: true,
13+
external: [
14+
"obsidian",
15+
"electron",
16+
"@codemirror/autocomplete",
17+
"@codemirror/collab",
18+
"@codemirror/commands",
19+
"@codemirror/language",
20+
"@codemirror/lint",
21+
"@codemirror/search",
22+
"@codemirror/state",
23+
"@codemirror/view",
24+
"@lezer/common",
25+
"@lezer/highlight",
26+
"@lezer/lr",
27+
...builtins,
28+
],
29+
format: "cjs",
30+
target: "es2022",
31+
logLevel: "info",
32+
sourcemap: prod ? false : "inline",
33+
treeShaking: true,
34+
outfile: "main.js",
35+
minify: prod,
36+
});
37+
38+
if (prod) {
39+
await ctx.rebuild();
40+
process.exit(0);
41+
} else {
42+
await ctx.watch();
43+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"id": "kb-staleness-badge",
3+
"name": "KB Staleness Badge",
4+
"version": "0.0.1",
5+
"minAppVersion": "1.5.0",
6+
"description": "Show a green/yellow/orange/red badge in the active note's title bar based on `last-validated:` frontmatter. Thresholds: 30/90/180 days, matching the stale-specs dashboard and release-qualification playbook.",
7+
"author": "Backbay",
8+
"authorUrl": "https://github.com/backbay-labs/chio-developer-base",
9+
"isDesktopOnly": false
10+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "kb-staleness-badge",
3+
"version": "0.0.1",
4+
"description": "Custom Obsidian plugin for chio-developer-base. Shows a staleness badge in the title bar based on the active note's `last-validated:` frontmatter.",
5+
"main": "main.js",
6+
"scripts": {
7+
"dev": "node esbuild.config.mjs",
8+
"build": "node esbuild.config.mjs production",
9+
"version": "node version-bump.mjs && git add manifest.json versions.json"
10+
},
11+
"keywords": ["obsidian", "plugin", "chio", "staleness", "kb-staleness-badge"],
12+
"author": "Backbay",
13+
"license": "Apache-2.0",
14+
"devDependencies": {
15+
"@types/node": "^20.0.0",
16+
"builtin-modules": "^3.3.0",
17+
"esbuild": "^0.20.0",
18+
"obsidian": "latest",
19+
"tslib": "^2.6.0",
20+
"typescript": "^5.4.0"
21+
}
22+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* KB Staleness Badge
3+
*
4+
* Shows a colored badge in the active note's title bar based on the
5+
* `last-validated:` frontmatter field. Helps make spec/playbook staleness
6+
* visible at a glance without opening the stale-specs dashboard.
7+
*
8+
* Thresholds match what stale-specs.md, the daily-note morning brief,
9+
* and the release-qualification playbook use:
10+
*
11+
* < 30 days → green (fresh)
12+
* 30..89 → yellow (gentle nudge)
13+
* 90..179 → orange (stale-specs query flags it)
14+
* >= 180 → red (release blocker per release-qualification)
15+
*
16+
* Notes without `last-validated:` get no badge. Notes whose value doesn't
17+
* parse as YYYY-MM-DD also get no badge — the plugin is best-effort, not
18+
* a contract.
19+
*/
20+
21+
import { MarkdownView, Plugin } from "obsidian";
22+
23+
interface StalenessSettings {
24+
yellowDays: number;
25+
orangeDays: number;
26+
redDays: number;
27+
}
28+
29+
const DEFAULT_SETTINGS: StalenessSettings = {
30+
yellowDays: 30,
31+
orangeDays: 90,
32+
redDays: 180,
33+
};
34+
35+
const COLORS = {
36+
green: "#2f855a",
37+
yellow: "#d99124",
38+
orange: "#dd6b20",
39+
red: "#d15451",
40+
};
41+
42+
const BADGE_CLASS = "kb-staleness-badge";
43+
44+
export default class StalenessBadge extends Plugin {
45+
settings!: StalenessSettings;
46+
47+
async onload() {
48+
await this.loadSettings();
49+
50+
this.registerEvent(
51+
this.app.workspace.on("active-leaf-change", () => this.refresh())
52+
);
53+
54+
this.registerEvent(
55+
this.app.metadataCache.on("changed", (file) => {
56+
const active = this.app.workspace.getActiveFile();
57+
if (active && active.path === file.path) this.refresh();
58+
})
59+
);
60+
61+
this.app.workspace.onLayoutReady(() => this.refresh());
62+
}
63+
64+
onunload() {
65+
this.removeBadges();
66+
}
67+
68+
private refresh() {
69+
this.removeBadges();
70+
71+
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
72+
if (!view || !view.file) return;
73+
74+
const cache = this.app.metadataCache.getFileCache(view.file);
75+
const lastValidated = cache?.frontmatter?.["last-validated"];
76+
if (!lastValidated) return;
77+
78+
const date = parseDate(lastValidated);
79+
if (!date) return;
80+
81+
const ageDays = Math.floor((Date.now() - date.getTime()) / 86_400_000);
82+
const color = this.colorFor(ageDays);
83+
this.renderBadge(view, ageDays, color);
84+
}
85+
86+
private colorFor(ageDays: number): string {
87+
if (ageDays >= this.settings.redDays) return COLORS.red;
88+
if (ageDays >= this.settings.orangeDays) return COLORS.orange;
89+
if (ageDays >= this.settings.yellowDays) return COLORS.yellow;
90+
return COLORS.green;
91+
}
92+
93+
private renderBadge(view: MarkdownView, ageDays: number, color: string) {
94+
const container = view.containerEl.querySelector(
95+
".view-header-title-container"
96+
) as HTMLElement | null;
97+
if (!container) return;
98+
99+
const badge = document.createElement("span");
100+
badge.classList.add(BADGE_CLASS);
101+
badge.textContent = `${ageDays}d`;
102+
badge.title =
103+
`Validated ${ageDays} day${ageDays === 1 ? "" : "s"} ago. ` +
104+
`Yellow at ${this.settings.yellowDays}+, orange at ${this.settings.orangeDays}+, ` +
105+
`red at ${this.settings.redDays}+ (release blocker per release-qualification playbook).`;
106+
107+
Object.assign(badge.style, {
108+
marginLeft: "8px",
109+
padding: "2px 6px",
110+
borderRadius: "3px",
111+
fontSize: "0.75em",
112+
fontWeight: "600",
113+
fontFamily: "var(--font-monospace)",
114+
color: "white",
115+
background: color,
116+
cursor: "help",
117+
});
118+
119+
container.appendChild(badge);
120+
}
121+
122+
private removeBadges() {
123+
document.querySelectorAll(`.${BADGE_CLASS}`).forEach((e) => e.remove());
124+
}
125+
126+
async loadSettings() {
127+
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
128+
}
129+
}
130+
131+
function parseDate(input: unknown): Date | null {
132+
if (!input) return null;
133+
if (input instanceof Date) return input;
134+
const s = String(input);
135+
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
136+
if (!m) return null;
137+
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
138+
return isNaN(d.getTime()) ? null : d;
139+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"compilerOptions": {
3+
"baseUrl": ".",
4+
"inlineSourceMap": true,
5+
"inlineSources": true,
6+
"module": "ESNext",
7+
"target": "ES2022",
8+
"allowJs": true,
9+
"noImplicitAny": true,
10+
"moduleResolution": "node",
11+
"importHelpers": true,
12+
"isolatedModules": true,
13+
"strictNullChecks": true,
14+
"lib": ["DOM", "ES2022"],
15+
"outDir": "."
16+
},
17+
"include": ["src/**/*.ts"]
18+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Bump the plugin version across manifest.json and versions.json.
3+
*
4+
* Usage:
5+
* npm version <patch|minor|major>
6+
*
7+
* Identical to episode-promoter/version-bump.mjs. The npm `version` lifecycle
8+
* runs this after package.json is updated; we mirror into the plugin's
9+
* Obsidian-facing manifest and the version → minAppVersion map.
10+
*/
11+
import { readFileSync, writeFileSync } from "node:fs";
12+
13+
const targetVersion = process.env.npm_package_version;
14+
if (!targetVersion) {
15+
console.error("Run via `npm version <patch|minor|major>` so npm sets npm_package_version.");
16+
process.exit(1);
17+
}
18+
19+
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
20+
const minAppVersion = manifest.minAppVersion;
21+
manifest.version = targetVersion;
22+
writeFileSync("manifest.json", JSON.stringify(manifest, null, 2) + "\n");
23+
24+
const versions = JSON.parse(readFileSync("versions.json", "utf8"));
25+
versions[targetVersion] = minAppVersion;
26+
writeFileSync("versions.json", JSON.stringify(versions, null, 2) + "\n");
27+
28+
console.log(`Bumped to ${targetVersion} (minAppVersion ${minAppVersion}).`);

0 commit comments

Comments
 (0)