Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
{
"name": "ccx",
"source": "./plugins/ccx",
"description": "Portable scratch-notebook core: per-thread STATE.md handoffs + compiled INDEX dashboard, 3 skills + 2 hooks. Language-agnostic, no ticket system required.",
"description": "Portable scratch-notebook core: per-thread STATE.md handoffs + compiled INDEX dashboard, 3 skills + a graph-backlink hook. Language-agnostic, no ticket system required.",
"category": "productivity",
"homepage": "https://github.com/shck-dev/ccx-context-system"
}
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: ci

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Run test suite
run: bun tests/run-tests.ts
- name: Install Claude Code CLI
run: npm install -g @anthropic-ai/claude-code
- name: Validate plugin + marketplace manifests
run: |
claude plugin validate ./plugins/ccx
claude plugin validate .
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A Claude Code **plugin marketplace** repo whose single plugin, `ccx` (`plugins/c

## Commands

- **Tests:** `bun tests/run-tests.ts` — single-file scenario harness (no test framework; `ok()` assertions, exits non-zero on failure). It builds fresh fixture projects in a tmpdir each run (a fake Go project on stock config + a custom-config project) and exercises every script and both hooks. There is no per-test runner; run the whole file.
- **Tests:** `bun tests/run-tests.ts` — single-file scenario harness (no test framework; `ok()` assertions, exits non-zero on failure). It builds fresh fixture projects in a tmpdir each run (a fake Go project on stock config + a custom-config project) and exercises every script and the backlink hook. There is no per-test runner; run the whole file.
- **Validate plugin structure:** `claude plugin validate ./plugins/ccx` (and `claude plugin validate .` for the marketplace).
- **Try changes live in one session:** `claude --plugin-dir ./plugins/ccx`.
- **Refresh an installed copy after edits:** `/plugin marketplace update ccx-context-system` (or restart the session).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ project's own language).
- Try changes in one session only: `claude --plugin-dir ./plugins/ccx`.
- Validate structure: `claude plugin validate ./plugins/ccx` (and `claude plugin validate .`).
- Tests: `bun tests/run-tests.ts` — application-scenario harness against fixture non-Node
projects (stock + custom config), covering every script and both hooks.
projects (stock + custom config), covering every script and the backlink hook.
887 changes: 887 additions & 0 deletions docs/superpowers/plans/2026-07-03-ccx-hardening.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion plugins/ccx/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ccx",
"displayName": "ccx — portable notebook core",
"version": "0.2.0",
"version": "0.3.0",
"description": "Scratch-notebook context management for any project: per-thread STATE.md handoff docs, a compiled parallel-session-safe INDEX dashboard (/ccx:save-state · /ccx:start-thread · /ccx:tidy-scratch), plus a graph-backlink hook for Obsidian. Language-agnostic, no ticket system required.",
"license": "MIT",
"author": { "name": "shck-dev", "url": "https://github.com/shck-dev" },
Expand Down
8 changes: 7 additions & 1 deletion plugins/ccx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,16 @@ Zero-config works. To customize, commit `methodology.config.json` at the project
"ticket_system": "none", // v1: none (linear/github adapters are future work)
"oneoff_script_runner": "bun", // referenced in scaffolded STATE docs
"index_title": null, // INDEX H1; null → project dir name
"script_extensions": ["ts", "js", "mjs", "cjs", "py", "sh"] // what scan counts as a script
"script_extensions": ["ts", "js", "mjs", "cjs", "py", "sh"], // what scan counts as a script
"extra_sections": [] // live INDEX sections: [{"title": "Environment", "command": "bun scripts/env.ts"}]
}
```

`extra_sections` lets a project inject live sections into the compiled INDEX (environment
probes, service health, anything a command can print). Each command runs at compile time with
a 5s cap; empty or failing output omits the section. Note the INDEX stays a pure render — if
your command's output varies run-to-run, so will those INDEX bytes.

## Obsidian (optional but recommended)

Open the scratch dir as a vault — threads cluster under their STATE, STATEs link to INDEX:
Expand Down
28 changes: 17 additions & 11 deletions plugins/ccx/scripts/backlink-scratch-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// rewrites existing lines); no-ops if the note already links anywhere. Disable via /hooks.

import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import { loadConfig } from "./lib/config";

const raw = await Bun.stdin.text();
Expand All @@ -20,34 +21,39 @@ if (data?.tool_name !== "Write") process.exit(0);
const fp: string = data?.tool_input?.file_path ?? "";
if (!fp) process.exit(0);

const cfg = loadConfig(process.env.CLAUDE_PROJECT_DIR || data?.cwd || process.cwd());
if (fp.includes(`/${cfg.archive_dir}/`)) process.exit(0); // archived notes are retired — leave them

// Must be a note inside a thread subdir of the scratch root — not STATE/INDEX, not top-level.
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const m = fp.match(new RegExp(`${esc(cfg.scratch_root)}/([^/]+)/([^/]+)\\.md$`));
if (!m) process.exit(0);
const [, dir, base] = m;
const root = process.env.CLAUDE_PROJECT_DIR || data?.cwd || process.cwd();
const cfg = loadConfig(root);

// Anchored: only notes under THIS project's scratch root, exactly one thread-dir deep.
const scratchAbs = resolve(root, cfg.scratch_root);
const fpAbs = resolve(root, fp);
if (!fpAbs.startsWith(scratchAbs + sep)) process.exit(0);
const rel = fpAbs.slice(scratchAbs.length + 1).split(sep);
if (rel.length !== 2) process.exit(0); // top-level or nested — not a thread note
const [dir, name] = rel;
if (dir === cfg.archive_dir || dir.startsWith(".")) process.exit(0); // archive + vault plumbing
if (!name.endsWith(".md")) process.exit(0);
const base = name.slice(0, -3);
const stateLink = cfg.state_basename.replace(/\.md$/, "");
const indexLink = cfg.index_basename.replace(/\.md$/, "");
if (base === stateLink || base === indexLink) process.exit(0);

let content = "";
try {
content = readFileSync(fp, "utf8");
content = readFileSync(fpAbs, "utf8");
} catch {
process.exit(0);
}
if (content.includes("[[")) process.exit(0); // already part of the graph — leave it

// Clean hierarchy: link the thread's STATE hub if it exists (STATE → INDEX carries the spine);
// only fall back to [[INDEX]] for a dir with no STATE.
const dirAbs = fp.slice(0, fp.lastIndexOf("/"));
const dirAbs = join(scratchAbs, dir);
const hasState = existsSync(`${dirAbs}/${cfg.state_basename}`);
const target = hasState ? `[[${stateLink}]]` : `[[${indexLink}]]`;

try {
writeFileSync(fp, `> ${target}\n\n` + content);
writeFileSync(fpAbs, `> ${target}\n\n` + content);
} catch {
process.exit(0);
}
Expand Down
12 changes: 9 additions & 3 deletions plugins/ccx/scripts/compile-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "
import { basename, join } from "node:path";
import { execSync } from "node:child_process";
import { loadConfig, projectRoot } from "./lib/config";
import { clip } from "./lib/text";

const ROOT = projectRoot();
const cfg = loadConfig(ROOT);
Expand Down Expand Up @@ -57,19 +58,19 @@ for (const slug of slugs) {
continue;
}
if (!st.isFile()) continue;
const txt = readFileSync(p, "utf8");
const txt = readFileSync(p, "utf8").replace(/\r\n/g, "\n");
const fm = parseFrontmatter(txt);
const kind = (fm.kind || "thread").toLowerCase();
const summary =
fm.summary ||
txt.match(/^\*\*Status:\*\*\s*(.+)$/m)?.[1]?.trim().split(/(?<=\.)\s/)[0] ||
"(no summary — add `summary:` to this STATE's frontmatter)";
all.push({ slug, kind, summary, mtime: st.mtimeMs });
all.push({ slug, kind, summary: clip(summary, 240), mtime: st.mtimeMs });
}

const live = (cmd: string) => {
try {
return execSync(cmd, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trimEnd();
return execSync(cmd, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trimEnd();
} catch {
return "";
}
Expand All @@ -85,6 +86,10 @@ const today = new Date().toISOString().slice(0, 10);
const title = cfg.index_title ?? basename(ROOT);
const worktrees = live("git worktree list");
const prs = live("gh pr list --limit 12");
const extras = cfg.extra_sections.flatMap((s) => {
const out = live(s.command);
return out ? [`## ${s.title}`, out, ""] : [];
});

const md = [
`# Work INDEX — ${title}`,
Expand All @@ -93,6 +98,7 @@ const md = [
`> \`${cfg.scratch_root}/<thread>/${cfg.state_basename}\` frontmatter (\`summary\`/\`kind\`) + live git — never hand-edited`,
`> (concurrency-safe: a pure render, atomic write). Detail lives in each STATE. Compiled: ${today}.`,
"",
...extras,
"## Active threads",
...(active.length
? active.map((t) => `- **${t.slug}** — ${t.summary} → [[${t.slug}/${stateLink}]]`)
Expand Down
48 changes: 46 additions & 2 deletions plugins/ccx/scripts/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export type CcxConfig = {
script_extensions: string[];
/** INDEX H1 suffix; null → the project dir name. */
index_title: string | null;
/** Extra live INDEX sections: each command runs at compile time (5s cap); empty output → omitted. */
extra_sections: Array<{ title: string; command: string }>;
};

export const DEFAULTS: CcxConfig = {
Expand All @@ -34,6 +36,7 @@ export const DEFAULTS: CcxConfig = {
oneoff_script_runner: "bun",
script_extensions: ["ts", "js", "mjs", "cjs", "py", "sh"],
index_title: null,
extra_sections: [],
};

export const CONFIG_BASENAME = "methodology.config.json";
Expand All @@ -43,12 +46,53 @@ export function projectRoot(): string {
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
}

/** Per-field validation: any invalid field silently falls back to its default (the documented
* "malformed config → defaults" contract, enforced per-field, not just per-file). */
function sanitize(user: unknown): Partial<CcxConfig> {
if (typeof user !== "object" || user === null || Array.isArray(user)) return {};
const u = user as Record<string, unknown>;
const out: Partial<CcxConfig> = {};
const isRelPath = (v: unknown): v is string =>
typeof v === "string" && v.length > 0 && !v.startsWith("/") && !v.includes("\\") &&
!v.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
const isBasename = (v: unknown): v is string =>
typeof v === "string" && v.length > 0 && !v.includes("/") && !v.includes("\\") && v !== "." && v !== "..";
const normRel = (v: unknown): string | null => {
if (typeof v !== "string") return null;
const n = v.replace(/^\.\//, "").replace(/\/+$/, "");
return isRelPath(n) ? n : null;
};
const scratchRoot = normRel(u.scratch_root);
if (scratchRoot !== null) out.scratch_root = scratchRoot;
if (isBasename(u.state_basename)) out.state_basename = u.state_basename;
if (isBasename(u.index_basename)) out.index_basename = u.index_basename;
if (isBasename(u.archive_dir)) out.archive_dir = u.archive_dir;
if (u.ticket_system === "none" || u.ticket_system === "linear" || u.ticket_system === "github")
out.ticket_system = u.ticket_system;
if (typeof u.oneoff_script_runner === "string" && u.oneoff_script_runner.trim().length > 0)
out.oneoff_script_runner = u.oneoff_script_runner.trim();
if (Array.isArray(u.script_extensions)) {
const exts = u.script_extensions.filter((e): e is string => typeof e === "string" && /^[a-z0-9]+$/i.test(e));
if (exts.length > 0) out.script_extensions = exts;
}
if (u.index_title === null || (typeof u.index_title === "string" && u.index_title.length > 0))
out.index_title = u.index_title as string | null;
if (Array.isArray(u.extra_sections)) {
out.extra_sections = u.extra_sections.filter(
(s): s is { title: string; command: string } =>
typeof s === "object" && s !== null &&
typeof (s as Record<string, unknown>).title === "string" && ((s as Record<string, unknown>).title as string).trim().length > 0 &&
typeof (s as Record<string, unknown>).command === "string" && ((s as Record<string, unknown>).command as string).trim().length > 0,
).map((s) => ({ title: s.title.replace(/\s+/g, " ").trim(), command: s.command.trim() }));
}
return out;
}

export function loadConfig(root: string = projectRoot()): CcxConfig {
const p = join(root, CONFIG_BASENAME);
if (!existsSync(p)) return DEFAULTS;
try {
const user = JSON.parse(readFileSync(p, "utf8"));
return { ...DEFAULTS, ...user };
return { ...DEFAULTS, ...sanitize(JSON.parse(readFileSync(p, "utf8"))) };
} catch {
return DEFAULTS;
}
Expand Down
17 changes: 13 additions & 4 deletions plugins/ccx/scripts/lib/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,25 @@
// extraction) — nothing else should ever re-implement identity.

export function slugify(topic: string): string {
const slug = topic
let slug = topic
.trim()
.normalize("NFC")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60)
.replace(/-+$/, "");
.slice(0, 60);
const last = slug.charCodeAt(slug.length - 1);
if (last >= 0xd800 && last <= 0xdbff) slug = slug.slice(0, -1); // truncation split a surrogate pair
slug = slug.replace(/-+$/, "");
return slug || "thread";
}

/** Loose identity: slugs that differ only by case/separators name the SAME thread
* (CP-1758 ≡ cp-1758 ≡ cp1758). Ticket adapters extend from here. */
export function normalizeForMatch(slug: string): string {
return slug.normalize("NFC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
}

/** Graph/INDEX label for a slug. v1: the slug itself. */
export function displayName(slug: string): string {
return slug;
Expand Down
12 changes: 12 additions & 0 deletions plugins/ccx/scripts/lib/text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Render-time guards for "one-liner" fields — the INDEX/threads dashboards stay a single pane
// even when a STATE's summary/Status has grown into a paragraph (detail belongs in the STATE).

/** Clip to at most `max` chars; overlong input is cut at max-1 (right-trimmed) + `…`.
* Never splits a surrogate pair — a cut landing inside one drops the dangling half. */
export function clip(s: string, max: number): string {
if (s.length <= max) return s;
let cut = s.slice(0, max - 1);
const last = cut.charCodeAt(cut.length - 1);
if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1); // lone high surrogate
return cut.trimEnd() + "…";
}
9 changes: 6 additions & 3 deletions plugins/ccx/scripts/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { loadConfig, projectRoot } from "./lib/config";
import { clip } from "./lib/text";

const root = projectRoot();
const cfg = loadConfig(root);
Expand All @@ -28,11 +29,13 @@ for (const slug of readdirSync(scratch).sort()) {
out.push(slug);
continue;
}
const txt = readFileSync(stateP, "utf8");
const txt = readFileSync(stateP, "utf8").replace(/\r\n/g, "\n");
const title = txt.match(/^# (.+)$/m)?.[1] ?? slug;
const status =
const status = clip(
txt.match(/^\*\*Status:\*\*\s*(.+)$/m)?.[1]?.trim() ??
`(none — add a **Status:** line to this ${cfg.state_basename})`;
`(none — add a **Status:** line to this ${cfg.state_basename})`,
200,
);
out.push(`- **${slug}** — ${title}\n status: ${status}\n (→ ${cfg.scratch_root}/${slug}/${cfg.state_basename})`);
}
console.log(out.length ? out.join("\n") : "(none)");
3 changes: 3 additions & 0 deletions plugins/ccx/skills/start-thread/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ Topic requested: **$ARGUMENTS**
a thread is any unit of work; ticket-system adapters may extend this later.
3. **Guard:** if `<scratch_root>/<slug>/` already has a STATE doc (see the injected config block
for the real paths), STOP — show its first heading and offer to open it instead of clobbering.
Treat slugs as the SAME thread when they match ignoring case and separators (`CP-1758` ≡
`cp-1758` ≡ `cp1758` — the `normalizeForMatch` rule in `scripts/lib/identity.ts`): a
near-match in the existing-threads list above → STOP the same way and offer the existing one.
4. **Seed the ask from the conversation** — there is no ticket system to pull from. If the goal
isn't clear from context, ask for one line. Mark anything unknown as TODO rather than inventing.
5. **Create** `<scratch_root>/<slug>/` and write the STATE doc from the template below, filling
Expand Down
13 changes: 9 additions & 4 deletions plugins/ccx/skills/tidy-scratch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ prints a plan and touches nothing until you confirm. If the scratch dir is not v
thread's topic/branch.
- **ARCHIVE (likely done)** — has a STATE doc AND `age > 30` AND no open PR. (Longer threshold
than a ticket-oracle setup would use: age alone must be more patient.)
- **DELETE (throwaway)** — `STATE = NO` AND `age > 14` (no handoff value + cold).
- **ADOPT (unfiled work)** — `STATE = NO` AND `files > 3` — too much accumulated work to be
throwaway; propose creating a STATE doc for it (offer /ccx:start-thread), never delete in
this pass.
- **DELETE (throwaway)** — `STATE = NO` AND `age > 14` AND `files ≤ 3` (no handoff value +
cold + tiny).
- **DELETE (empty)** — `files = 0`.
- **FLAG → default KEEP (unsure)** — anything that fits no rule above, and every `kind: hub`
reference note. **Never auto-propose deleting a folder that has a STATE doc.**
Expand All @@ -45,7 +49,7 @@ prints a plan and touches nothing until you confirm. If the scratch dir is not v
| item | class | proposed action | why | last edit (age) |
|---|---|---|---|---|

End with a one-line tally: `N delete · M archive · rest keep`.
End with a one-line tally: `N delete · M archive · K adopt · rest keep`.
3. **Confirm.** Ask: apply **all**, a **subset** (named), or **none**. Wait for the answer.
4. **Execute only what was approved**, echoing each action (paths from the injected config block):
- **archive:** `mkdir -p <scratch_root>/<archive_dir> && mv <scratch_root>/<slug> <scratch_root>/<archive_dir>/`,
Expand All @@ -57,8 +61,9 @@ prints a plan and touches nothing until you confirm. If the scratch dir is not v
## Safety rails (non-negotiable)

- **Dry-run is mandatory** — never delete, move, or prune before the step-3 confirmation.
- **Hard `rm` is only ever proposed for folders with no STATE doc.** Anything carrying a STATE is
*archived* (moved under the archive dir), never deleted — its handoff notes survive.
- **Hard `rm` is only ever proposed for folders with no STATE doc AND ≤3 files.** Anything
carrying a STATE — or carrying real bulk (>3 files) — is *archived* or *adopted*, never
deleted; its work survives.
- **When unsure, archive, don't delete** — without a ticket system there is no authoritative
"this is finished" signal, only age.
- **Reads the clock every run** (`scan.ts` + `date`) — no date is ever hardcoded, not even the
Expand Down
Loading
Loading