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: 2 additions & 0 deletions docs/internals/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ Committed: `nodes/`, `ENTRY.md`, `GRAPH.md`, `FOLDER_SUMMARIES.md`, `.config/pro

Per-run curator state lives under `_logs/curator/`: `<run>__<n>.draft.json` (a batch's actions and the sessions it consumed, with their transcript hashes) and the JSONL traces. `logs prune` deletes only the `.jsonl` files.

`doctor` resolves candidate provenance (`<session_id>:<practice|map>:<index>`) against the referenced session log's frontmatter and indexed proposal, including processed logs staged by `session-log stage-live`. It preserves node metadata. Session logs are gitignored by default, so a missing source on another clone or after manual deletion still produces an advisory dangling-reference warning. `logsRetentionDays` applies only to `_logs/*.jsonl`, not `_sessions/*.md`.

## Locking

Only the proposal-drain hook locks, with `proper-lockfile` on `state.json`, a heartbeat, and a 60 second stale threshold. A drain the host kills is reclaimed by the next drain within a minute.
Expand Down
34 changes: 33 additions & 1 deletion src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ import {
} from '../lib/nodes.js';
import { findRepoRoot, packageTemplatesDir, repoPaths } from '../lib/paths.js';
import { readRedirectsLedger, resolveRedirect } from '../lib/redirects.js';
import { IndexFrontmatterSchema, SettingsSchema } from '../lib/schemas.js';
import {
IndexFrontmatterSchema,
ProposalCandidateSchema,
SessionLogFrontmatterSchema,
SettingsSchema,
} from '../lib/schemas.js';
import { findSessionLogBySessionId } from '../lib/session-log.js';
import { packageVersion } from '../lib/version.js';

const EXPECTED_PROMPTS = ['proposal-extract.md'];
Expand Down Expand Up @@ -168,6 +174,8 @@ export async function runDoctor(opts: DoctorOptions): Promise<number> {
* - It is a bare filename and exists under `_sessions/`.
* - It is a repo-relative path and exists when resolved against `root`.
* - It is an absolute path and exists as-is.
* - It is a candidate identity whose session log contains the indexed
* practice/map proposal, including logs already processed by curation.
* - It is a legacy retired id whose redirect chain
* reaches at least one live node. Preserve these citations as recorded.
*/
Expand All @@ -184,6 +192,7 @@ export function collectDanglingDerivedFrom(
for (const node of nodes) {
for (const ref of node.frontmatter.kk_derived_from) {
if (resolvesOnDisk(ref, root, sessionsDir)) continue;
if (resolvesCandidateOrigin(ref, sessionsDir)) continue;
if (Object.hasOwn(redirects, ref) && resolveRedirect(redirects, liveIds, ref).length > 0)
continue;
out.push({ nodeId: node.frontmatter.kk_id, reference: ref });
Expand All @@ -201,6 +210,29 @@ function resolvesOnDisk(ref: string, root: string, sessionsDir: string): boolean
return false;
}

function resolvesCandidateOrigin(ref: string, sessionsDir: string): boolean {
const match = /^([^:/\\\s]+):(practice|map):(0|[1-9]\d*)$/.exec(ref);
if (!match) return false;
const [, sessionId, kind, rawIndex] = match;
if (!sessionId || (kind !== 'practice' && kind !== 'map')) return false;
const index = Number(rawIndex);
if (!Number.isSafeInteger(index)) return false;
try {
const filename = findSessionLogBySessionId(sessionsDir, sessionId);
if (!filename) return false;
const parsed = matter(readFileSync(join(sessionsDir, filename), 'utf8'));
const fm = SessionLogFrontmatterSchema.safeParse(parsed.data);
if (!fm.success || fm.data.session_id !== sessionId) return false;
// Provenance outlives queue eligibility: read the retained proposal even
// when a curator stamp makes this session ineligible for consumption.
const proposal = ProposalCandidateSchema.safeParse(fm.data.proposals[kind][index]);
return proposal.success && proposal.data.type === kind;
} catch {
// An unreadable or malformed source remains a dangling reference.
return false;
}
}

/**
* Advisory freshness signal: how many nodes may describe source code that
* changed since they were last curated. Always a warn (never a failure) so it
Expand Down
151 changes: 149 additions & 2 deletions tests/doctor-dangling.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,48 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import matter from 'gray-matter';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { collectDanglingDerivedFrom } from '../src/commands/doctor.js';
import { markSessionsProcessed } from '../src/lib/curate.js';
import { buildSessionLogFilename, renderSessionLog } from '../src/lib/session-log.js';
import { cliPath, runCli } from './helpers.js';

const SESSION_ID = 'b2225129-5ef2-4f05-a52f-c5e72c04d34c';
const CAPTURED_AT = '2026-05-11T10:00:00Z';
const PROPOSALS = {
practice: [
{
type: 'practice',
tags: [],
title: 'Practice',
description: 's',
body: 'b',
kk_confidence: 'high',
},
],
map: [
{ type: 'map', tags: [], title: 'Map', description: 's', body: 'b', kk_confidence: 'high' },
],
};

function writeSession(harness: Harness): string {
const file = join(harness.sessionsDir, buildSessionLogFilename(CAPTURED_AT, SESSION_ID));
writeFileSync(
file,
renderSessionLog({
sessionId: SESSION_ID,
capturedBy: 'stop',
capturedAt: CAPTURED_AT,
transcriptHash: 'sha256:abc',
body: 'Transcript',
proposalStatus: 'done',
proposals: PROPOSALS,
})
);
return file;
}

interface Harness {
root: string;
Expand Down Expand Up @@ -46,7 +85,13 @@ describe('collectDanglingDerivedFrom', () => {
writeFileSync(join(harness.sessionsDir, 'session-a.md'), 'x');
mkdirSync(join(harness.root, 'docs'), { recursive: true });
writeFileSync(join(harness.root, 'docs/auth.md'), 'x');
writeNode(harness, 'practice-a', ['session-a.md', 'docs/auth.md']);
writeNode(harness, 'practice-a', [
'session-a.md',
'docs/auth.md',
join(harness.root, 'docs/auth.md'),
'https://example.com/source',
'file:///external/source.md',
]);
expect(collectDanglingDerivedFrom(harness.root, harness.nodesDir, harness.sessionsDir)).toEqual(
[]
);
Expand Down Expand Up @@ -75,4 +120,106 @@ describe('collectDanglingDerivedFrom', () => {
);
expect(out).toEqual([]);
});

it.each([false, true])(
'resolves practice/map candidates in a session, processed=%s',
async processed => {
const file = writeSession(harness);
if (processed) {
await markSessionsProcessed(
[{ path: file, transcript_hash: 'sha256:abc' }],
'run',
new Date(CAPTURED_AT)
);
}
writeNode(harness, 'practice-a', [`${SESSION_ID}:practice:0`, `${SESSION_ID}:map:0`]);
const nodePath = join(harness.nodesDir, 'practice-a.md');
const before = [readFileSync(nodePath, 'utf8'), readFileSync(file, 'utf8')];

expect(
collectDanglingDerivedFrom(harness.root, harness.nodesDir, harness.sessionsDir)
).toEqual([]);
expect([readFileSync(nodePath, 'utf8'), readFileSync(file, 'utf8')]).toEqual(before);
}
);

it('reports missing sessions, missing proposals, invalid indices and malformed identities', () => {
const file = writeSession(harness);
const parsed = matter(readFileSync(file, 'utf8'));
parsed.data['proposals'] = { practice: PROPOSALS.practice, map: [] };
writeFileSync(file, matter.stringify(parsed.content, parsed.data));
const refs = [
'11111111-2222-4333-8444-555555555555:practice:0',
`${SESSION_ID}:map:0`,
`${SESSION_ID}:practice:1`,
`${SESSION_ID}:practice:-1`,
`${SESSION_ID}:practice:01`,
`${SESSION_ID}:practice:0.5`,
`${SESSION_ID}:practice:9007199254740992`,
`${SESSION_ID}:practice:`,
`${SESSION_ID}:practice:0:extra`,
`${SESSION_ID}:unknown:0`,
':practice:0',
];
writeNode(harness, 'practice-a', [`${SESSION_ID}:practice:0`, ...refs]);
expect(collectDanglingDerivedFrom(harness.root, harness.nodesDir, harness.sessionsDir)).toEqual(
refs.map(reference => ({ nodeId: 'practice-a', reference }))
);
});

it('does not resolve an identity from a filename alone or an invalid proposal', () => {
const file = writeSession(harness);
writeNode(harness, 'practice-a', [`${SESSION_ID}:practice:0`]);
const original = readFileSync(file, 'utf8');
const { data, content } = matter(original);
for (const contents of [
'---\nbroken: [\n---\n',
matter.stringify(content, { ...data, session_id: 'another-session' }),
matter.stringify(content, { ...data, proposals: { practice: [null], map: [] } }),
matter.stringify(content, { ...data, proposals: { practice: PROPOSALS.map, map: [] } }),
]) {
writeFileSync(file, contents);
expect(
collectDanglingDerivedFrom(harness.root, harness.nodesDir, harness.sessionsDir)
).toEqual([{ nodeId: 'practice-a', reference: `${SESSION_ID}:practice:0` }]);
}
});

it('resolves a processed stage-live session in the built CLI and warns after its removal', async () => {
const staged = spawnSync(
process.execPath,
[cliPath, 'session-log', 'stage-live', '--session-id', SESSION_ID],
{
cwd: harness.root,
input: JSON.stringify(PROPOSALS),
encoding: 'utf8',
}
);
expect(staged.status, staged.stderr).toBe(0);
const { path } = JSON.parse(staged.stdout) as { path: string };
const parsed = matter(readFileSync(path, 'utf8'));
await markSessionsProcessed(
[{ path, transcript_hash: parsed.data['transcript_hash'] as string }],
'run',
new Date(CAPTURED_AT)
);
const refs = [`${SESSION_ID}:practice:0`, `${SESSION_ID}:map:0`];
writeNode(harness, 'practice-a', refs);
const nodePath = join(harness.nodesDir, 'practice-a.md');
const before = readFileSync(nodePath, 'utf8');
const result = await runCli(harness.root, ['doctor', '--verbose']);
expect(result.stdout + result.stderr).toContain(
'derived_from references resolve: no dangling references'
);
expect(readFileSync(nodePath, 'utf8')).toBe(before);

rmSync(path);
const missing = await runCli(harness.root, ['doctor', '--verbose']);
expect(missing.stdout + missing.stderr).toContain(
'derived_from references resolve: 2 dangling reference(s)'
);
for (const ref of refs)
expect(missing.stdout + missing.stderr).toContain(` - practice-a: ${ref}`);
expect(readFileSync(nodePath, 'utf8')).toBe(before);
});
});
Loading