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
9 changes: 9 additions & 0 deletions config/seed-manifest.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,12 @@ projects:
- .claude/commands/*.md
- .claude/skills/**/SKILL.md
- docs/**/*.md

# Optional: worksets group related projects into one recall working set.
# `threadnote recall --workset <name>` (or a query mentioning the name) expands
# recall across every member's durable + seeded scope. Unknown member names are
# ignored. Inspect with `threadnote workset list` / `threadnote workset show`.
# worksets:
# - name: example-stack
# description: service plus its client
# projects: [example-service, example-client]
181 changes: 181 additions & 0 deletions src/graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {readFile} from 'node:fs/promises';
import {join} from 'node:path';

export interface DependencyFacts {
readonly dependencies: readonly string[];
readonly ecosystems: readonly string[];
readonly manifestFiles: readonly string[];
readonly publishedName?: string;
}

export interface GraphEdge {
readonly dependency: string;
readonly project: string;
}

async function readIfExists(path: string): Promise<string | undefined> {
try {
return await readFile(path, 'utf8');
} catch {
return undefined;
}
}

function parsePackageJson(
content: string,
): {readonly dependencies: readonly string[]; readonly publishedName?: string} | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return undefined;
}
const object = parsed as Record<string, unknown>;
const dependencies = new Set<string>();
for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
const section = object[key];
if (section && typeof section === 'object' && !Array.isArray(section)) {
for (const name of Object.keys(section as Record<string, unknown>)) {
dependencies.add(name);
}
}
}
return {dependencies: [...dependencies], publishedName: typeof object.name === 'string' ? object.name : undefined};
}

function parseGoMod(content: string): {readonly dependencies: readonly string[]; readonly publishedName?: string} {
const dependencies = new Set<string>();
let publishedName: string | undefined;
let inRequireBlock = false;
for (const rawLine of content.split('\n')) {
const line = rawLine.replace(/\/\/.*$/, '').trim();
if (!line) {
continue;
}
const moduleMatch = /^module\s+(\S+)/.exec(line);
if (moduleMatch) {
publishedName = moduleMatch[1];
continue;
}
if (/^require\s*\($/.test(line)) {
inRequireBlock = true;
continue;
}
if (inRequireBlock) {
if (line === ')') {
inRequireBlock = false;
continue;
}
const blockMatch = /^(\S+)\s+v\S+/.exec(line);
if (blockMatch) {
dependencies.add(blockMatch[1]);
}
continue;
}
const singleMatch = /^require\s+(\S+)\s+v\S+/.exec(line);
if (singleMatch) {
dependencies.add(singleMatch[1]);
}
}
return {dependencies: [...dependencies], publishedName};
}

/**
* Extracts declarative dependency facts from a project's root manifest files.
* Deterministic formats only (npm package.json + go.mod) so we never emit wrong
* edges; unparseable files are ignored. No AST, lockfiles, or API analysis.
*/
export async function extractDependencyFacts(projectRoot: string): Promise<DependencyFacts> {
const dependencies = new Set<string>();
const ecosystems: string[] = [];
const manifestFiles: string[] = [];
let publishedName: string | undefined;

const packageJson = await readIfExists(join(projectRoot, 'package.json'));
if (packageJson !== undefined) {
const parsed = parsePackageJson(packageJson);
if (parsed) {
manifestFiles.push('package.json');
ecosystems.push('npm');
publishedName = publishedName ?? parsed.publishedName;
for (const dependency of parsed.dependencies) {
dependencies.add(dependency);
}
}
}

const goMod = await readIfExists(join(projectRoot, 'go.mod'));
if (goMod !== undefined) {
const parsed = parseGoMod(goMod);
manifestFiles.push('go.mod');
ecosystems.push('go');
publishedName = publishedName ?? parsed.publishedName;
for (const dependency of parsed.dependencies) {
dependencies.add(dependency);
}
}

return {dependencies: [...dependencies].sort(), ecosystems, manifestFiles, publishedName};
}

/**
* Renders a project's dependency facts as a plain-Markdown resource document.
* In-workspace dependencies become `[[project]]` wiki-links so recall can bridge
* repos; everything else is counted. Emitted verbatim as a seeded resource —
* NOT a memory, so it carries no MEMORY/kind header.
*/
export function buildGraphDocument(params: {
readonly externalCount: number;
readonly facts: DependencyFacts;
readonly internalEdges: readonly GraphEdge[];
readonly projectName: string;
}): string {
const {externalCount, facts, internalEdges, projectName} = params;
const lines = [
`# ${projectName} — dependency facts`,
'',
'Auto-generated by `threadnote seed --graph` from declarative manifest files. A static snapshot of package metadata, not a live dependency graph.',
'',
`provides: ${facts.publishedName ?? '(none declared)'}`,
`ecosystems: ${facts.ecosystems.length > 0 ? facts.ecosystems.join(', ') : '(none detected)'}`,
'',
'manifests:',
...(facts.manifestFiles.length > 0 ? facts.manifestFiles.map(file => `- ${file}`) : ['- (none)']),
'',
'depends_on (within this workspace):',
...(internalEdges.length > 0
? internalEdges.map(edge => `- [[${edge.project}]] (via ${edge.dependency})`)
: ['- (no in-workspace dependencies detected)']),
'',
`external dependencies: ${externalCount} declared`,
];
return `${lines.join('\n')}\n`;
}

/**
* Resolves a project's raw dependency names against the published-name → project
* map, splitting them into in-workspace edges (deduped by target project) and a
* count of external deps. Self-edges are dropped.
*/
export function resolveGraphEdges(
projectName: string,
dependencies: readonly string[],
projectByPublishedName: ReadonlyMap<string, string>,
): {readonly externalCount: number; readonly internalEdges: readonly GraphEdge[]} {
const internalEdges: GraphEdge[] = [];
const seenTargets = new Set<string>();
let externalCount = 0;
for (const dependency of dependencies) {
const target = projectByPublishedName.get(dependency.toLowerCase());
if (target && target !== projectName && !seenTargets.has(target)) {
seenTargets.add(target);
internalEdges.push({dependency, project: target});
} else if (!target) {
externalCount += 1;
}
}
return {externalCount, internalEdges};
}
97 changes: 97 additions & 0 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
} from './constants.js';
import {parseAgentClient} from './mcp.js';
import {runHandoff, runRecall} from './memory.js';
import {applyScrubber} from './share.js';
import {distillTrace} from './trace.js';
import type {AgentClient, HookRunnerOptions, HooksInstallOptions, JsonObject, RuntimeConfig} from './types.js';
import {checkForThreadnoteUpdate, spawnDetachedAutoUpdate} from './update-check.js';
import {expandPath, exists, isJsonObject, parseJsonConfigObject, resolveRepoName} from './utils.js';
Expand Down Expand Up @@ -193,16 +195,19 @@ export async function runPreCompactHook(config: RuntimeConfig, options: HookRunn
// and the process still exits 0 — the worst-case is a missed snapshot.
try {
const project = (await resolveRepoName()) ?? 'general';
const {sessionId, trace} = await captureTraceContext();
await runHandoff(config, {
blockers: '- none recorded',
dryRun: options.dryRun === true,
nextStep:
'Continue from this auto-snapshot. A manual `threadnote handoff` will produce a richer write-up if you have more context.',
project,
sessionId,
sourceAgentClient: 'claude',
task: 'Auto-snapshot captured at Claude PreCompact (deterministic safety net before context compaction).',
tests: '- not recorded (auto-snapshot)',
topic: HOOK_AUTO_PRECOMPACT_TOPIC,
trace,
});
} catch (err: unknown) {
process.stderr.write(
Expand Down Expand Up @@ -235,6 +240,98 @@ export async function runSessionStartHook(config: RuntimeConfig, options: HookRu
}
}

interface TraceContext {
readonly sessionId?: string;
readonly trace?: string;
}

interface HookPayload {
readonly sessionId?: string;
readonly transcriptPath?: string;
}

/**
* Reads Claude's PreCompact stdin payload and distills the referenced
* transcript into a short, scrubbed trace. Entirely best-effort: any failure
* (no stdin, unreadable transcript, unstable format, secret blocker) yields an
* empty context so the pre-compact snapshot still writes its state-only
* handoff. Never throws.
*/
async function captureTraceContext(): Promise<TraceContext> {
try {
const payload = await readHookPayload();
if (!payload) {
return {};
}
const rawTrace = payload.transcriptPath ? await distillTrace(payload.transcriptPath) : undefined;
return {sessionId: payload.sessionId, trace: rawTrace ? scrubTrace(rawTrace) : undefined};
} catch {
return {};
}
}

/** Redacts soft leaks; drops the trace on a hard credential blocker. */
function scrubTrace(trace: string): string | undefined {
const result = applyScrubber(trace, {redact: true});
return result.blocker ? undefined : result.cleaned;
}

async function readHookPayload(): Promise<HookPayload | undefined> {
if (process.stdin.isTTY) {
return undefined;
}
const raw = await readStdinWithTimeout(1500, 512 * 1024);
if (!raw.trim()) {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return undefined;
}
if (!isJsonObject(parsed)) {
return undefined;
}
return {
sessionId: typeof parsed.session_id === 'string' ? parsed.session_id : undefined,
transcriptPath: typeof parsed.transcript_path === 'string' ? parsed.transcript_path : undefined,
};
}

function readStdinWithTimeout(timeoutMs: number, maxBytes: number): Promise<string> {
return new Promise(resolve => {
const stdin = process.stdin;
let data = '';
let settled = false;
const finish = (): void => {
if (settled) {
return;
}
settled = true;
stdin.off('data', onData);
stdin.off('end', finish);
stdin.off('error', finish);
clearTimeout(timer);
stdin.pause();
resolve(data);
};
const onData = (chunk: string): void => {
data += chunk;
if (data.length >= maxBytes) {
data = data.slice(0, maxBytes);
finish();
}
};
const timer = setTimeout(finish, timeoutMs);
stdin.setEncoding('utf8');
stdin.on('data', onData);
stdin.on('end', finish);
stdin.on('error', finish);
stdin.resume();
});
}

async function emitUpdateBannerIfOutdated(config: RuntimeConfig): Promise<void> {
// Cheap, daily-cached check that nags users to upgrade. The check is wrapped
// in try/catch so a flaky registry or unreachable network never breaks the
Expand Down
Loading
Loading