Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e711819
Chunks 0a + 0b: prep — land 0.33→0.34 evolve, reconcile config drift
spaceshipmike Jun 7, 2026
ad5d65f
Release 0.6.1-beta.12 (chunks 0a+0b: spec evolve to 0.34)
spaceshipmike Jun 7, 2026
326575b
Chunk A1: schema v17 — interactions append-only log
spaceshipmike Jun 7, 2026
0b30c8c
Release 0.6.1-beta.13 (chunk A1: schema v17 interactions table)
spaceshipmike Jun 7, 2026
b39ac2b
Chunk A2: open-vocabulary normalization at write boundary (S199, S201…
spaceshipmike Jun 7, 2026
63670e0
Release 0.6.1-beta.14 (chunk A2: open-vocab normalization)
spaceshipmike Jun 7, 2026
acd2867
Chunk A3: vocab MCP tool (tool #58) (S200)
spaceshipmike Jun 7, 2026
af64c4c
Release 0.6.1-beta.15 (chunk A3: vocab MCP tool)
spaceshipmike Jun 7, 2026
5df2c15
Chunk A4: interactions logging on every read surface + retention prun…
spaceshipmike Jun 7, 2026
0084a56
Release 0.6.1-beta.16 (chunk A4: interactions logging on read surfaces)
spaceshipmike Jun 7, 2026
3091519
Chunk A5: ambiguous envelope on identity-shaped queries (S208, S209)
spaceshipmike Jun 7, 2026
096d1ac
Release 0.6.1-beta.17 (chunk A5: ambiguous envelope on identity queries)
spaceshipmike Jun 7, 2026
a38b3df
Chunk A6 [GATE]: MCP instructions proactive-use directive (S197)
spaceshipmike Jun 7, 2026
762bb06
Release 0.6.1-beta.18 (chunk A6 [GATE]: instructions rewrite — Cluste…
spaceshipmike Jun 7, 2026
d1b85b8
Fix interactions log signal integrity (review findings #1, #4, #7)
spaceshipmike Jun 7, 2026
fd01833
Fix vocab normalization edge cases (review findings #3, #9)
spaceshipmike Jun 7, 2026
e151844
Fix configure_memory validation + align prune semantics docs (review …
spaceshipmike Jun 7, 2026
9d76215
Fix ambiguous envelope contract + leaks + short-query trigger (review…
spaceshipmike Jun 7, 2026
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
129 changes: 129 additions & 0 deletions .fctry/changelog.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions .fctry/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"versions": {
"external": {
"type": "external",
"current": "0.6.1-beta.4",
"current": "0.6.1-beta.18",
"propagationTargets": [
{
"file": "package.json",
Expand Down Expand Up @@ -33,7 +33,7 @@
},
"spec": {
"type": "internal",
"current": "0.32",
"current": "0.34",
"propagationTargets": [
{
"file": ".fctry/spec.md",
Expand Down
609 changes: 609 additions & 0 deletions .fctry/scenarios.md

Large diffs are not rendered by default.

345 changes: 317 additions & 28 deletions .fctry/spec.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "setlist",
"private": true,
"version": "0.6.1-beta.11",
"version": "0.6.1-beta.18",
"license": "Apache-2.0",
"type": "module",
"workspaces": [
Expand Down
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@setlist/app",
"version": "0.6.1-beta.11",
"version": "0.6.1-beta.18",
"private": true,
"type": "module",
"main": "./out/main/index.js",
Expand Down
15 changes: 11 additions & 4 deletions packages/app/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ function summarizePinnedProject(project: Record<string, unknown> | null): Pinned
export function listPinnedProjects(): PinnedProjectSummary[] {
const reg = getRegistry();
return getPinnedProjects()
.map(projectName => summarizePinnedProject(reg.getProject(projectName, 'full')))
// Pinned-menu refresh runs frequently in the background — not a user-initiated
// get_project. Suppress the interactions log so S204 stays meaningful.
.map(projectName => summarizePinnedProject(reg.getProject(projectName, 'full', { logInteraction: false })))
.filter((project): project is PinnedProjectSummary => project !== null);
}

Expand Down Expand Up @@ -170,7 +172,8 @@ export function registerIpcHandlers(ipcMain: IpcMain, opts?: {

ipcMain.handle('updateCore', (_e, name: string, updates: Parameters<Registry['updateCore']>[1]) => {
reg.updateCore(name, updates);
return reg.getProject(name, 'standard');
// Post-update read — internal, do not contribute to interactions signal.
return reg.getProject(name, 'standard', { logInteraction: false });
});

ipcMain.handle('updateFields', (_e, name: string, fields: Record<string, unknown>, producer?: string) => {
Expand Down Expand Up @@ -325,7 +328,9 @@ export function registerIpcHandlers(ipcMain: IpcMain, opts?: {
});

ipcMain.handle('projectActions:openPath', async (_e, projectName: string, selectedPath?: string) => {
const project = reg.getProject(projectName, 'full');
// openPath uses the project record to resolve the on-disk path; this is an
// implementation detail of the user's "open folder" intent, not a read query.
const project = reg.getProject(projectName, 'full', { logInteraction: false });
const paths = Array.isArray(project?.paths) ? project.paths : [];
const path = selectedPath ?? paths[0];
if (!path || !paths.includes(path)) {
Expand All @@ -336,7 +341,9 @@ export function registerIpcHandlers(ipcMain: IpcMain, opts?: {
});

ipcMain.handle('projectActions:copyBriefCommand', (_e, projectName: string) => {
const project = reg.getProject(projectName, 'summary');
// copyBriefCommand only needs the record to verify existence — not a user
// get_project read. Suppress the interactions log.
const project = reg.getProject(projectName, 'summary', { logInteraction: false });
if (!project) throw new Error(`Project not found: ${projectName}`);
const command = projectBriefCommand(projectName);
clipboard.writeText(command);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@setlist/cli",
"version": "0.6.1-beta.11",
"version": "0.6.1-beta.18",
"license": "Apache-2.0",
"type": "module",
"main": "./dist/index.js",
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,9 @@ export interface RefreshResult {
}

export async function refreshProjectDigest(registry: Registry, projectName: string, opts?: { onlyStale?: boolean }): Promise<RefreshResult> {
const project = registry.getProject(projectName, 'full');
// Digest refresh is an internal CLI worker pass — not a user-initiated read.
// Suppress the interactions log so S204 signal reflects only direct surfaces.
const project = registry.getProject(projectName, 'full', { logInteraction: false });
if (!project) return { project_name: projectName, status: 'error', error: `Project not found: ${projectName}` };
const paths = (project as { paths?: string[] }).paths ?? [];
if (paths.length === 0) return { project_name: projectName, status: 'skipped-no-path' };
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@setlist/core",
"version": "0.6.1-beta.11",
"version": "0.6.1-beta.18",
"license": "Apache-2.0",
"type": "module",
"main": "./dist/index.js",
Expand Down
73 changes: 73 additions & 0 deletions packages/core/src/ambiguity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// @fctry: #cross-project
//
// Spec 0.34 — ambiguous-envelope detection for identity-shaped queries.
// Shared between search_projects, recall, and cross_query.
//
// Contract (#cross-project 2.9, S208/S209):
// - When the 2nd-place candidate's score is within ~15% (relative gap) of
// the top candidate, the response is ambiguous.
// - Up to 4 alternatives surfaced, each as `{name, score, why}`.
// - The threshold is a RELATIVE gap: (top - second) / top <= 0.15.
// - Identity-resolution surfaces always return the envelope shape
// `{result, ambiguous, alternatives}` — `ambiguous` defaults to `false`,
// `alternatives` defaults to `[]`. Callers iterating `response.result`
// work on every call regardless of outcome.
// - cross_query filters the candidate pool to `source === 'registry'` so
// memory/cc_memory hits (often carrying project='global') never surface
// as registered-project alternatives.
// - Single- and two-character queries skip ambiguity detection entirely.

/** Spec literal: 15% relative gap (S208). */
export const AMBIGUITY_GAP_THRESHOLD = 0.15;

/** Spec literal: up to 4 alternatives surfaced (S208). */
export const MAX_ALTERNATIVES = 4;

/** One candidate in the ranked list — the input to ambiguity detection. */
export interface AmbiguityCandidate {
name: string;
score: number;
/** One-line rationale; surfaced verbatim as the `why` field on alternatives. */
why?: string;
}

/** Ambiguity envelope returned by the detection helper. */
export interface AmbiguityVerdict {
ambiguous: boolean;
alternatives: { name: string; score: number; why: string }[];
}

/**
* Detect ambiguity in a ranked candidate list. Returns `{ambiguous,
* alternatives}` — `alternatives` is empty when unambiguous.
*
* Requires `candidates` to be SORTED BY SCORE DESCENDING. The caller is
* responsible for ranking; this helper only looks at the relative gap
* between positions 0 and 1.
*
* Edge cases:
* - 0 or 1 candidates → unambiguous (nothing to be ambiguous about)
* - top score ≤ 0 → unambiguous (degenerate ranking)
* - 2nd-place score ≤ 0 → unambiguous (the top is clearly ahead)
*/
export function detectAmbiguity(candidates: readonly AmbiguityCandidate[]): AmbiguityVerdict {
if (candidates.length < 2) {
return { ambiguous: false, alternatives: [] };
}
const top = candidates[0];
const second = candidates[1];
if (top.score <= 0 || second.score <= 0) {
return { ambiguous: false, alternatives: [] };
}
const gap = (top.score - second.score) / top.score;
if (gap > AMBIGUITY_GAP_THRESHOLD) {
return { ambiguous: false, alternatives: [] };
}
// Ambiguous — surface up to MAX_ALTERNATIVES (positions 1..MAX_ALTERNATIVES).
const alternatives = candidates.slice(1, 1 + MAX_ALTERNATIVES).map(c => ({
name: c.name,
score: c.score,
why: c.why ?? '',
}));
return { ambiguous: true, alternatives };
}
10 changes: 6 additions & 4 deletions packages/core/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,8 @@ export class Bootstrap {

// Check project name isn't already registered
const registry = new Registry(this._dbPath);
const existing = registry.getProject(opts.name);
// Bootstrap pre-flight duplicate check — internal, must not log to interactions.
const existing = registry.getProject(opts.name, 'standard', { logInteraction: false });
if (existing) {
throw new RegistryError(
'DUPLICATE',
Expand Down Expand Up @@ -488,8 +489,8 @@ export class Bootstrap {
return { ...result, folders_moved };
}

// Get project paths
const project = registry.getProject(name, 'standard');
// Get project paths — internal archive-move lookup, must not log to interactions.
const project = registry.getProject(name, 'standard', { logInteraction: false });
const paths = (project as Record<string, unknown>)?.paths as string[] | undefined;
if (!paths || paths.length === 0) {
return { ...result, folders_moved };
Expand Down Expand Up @@ -665,7 +666,8 @@ export class Bootstrap {
// If the project name is already registered, fail fast before running
// the recipe. (The recipe runner does not know about the registry.)
const registry = new Registry(this._dbPath);
if (registry.getProject(opts.name)) {
// Recipe-bootstrap pre-flight duplicate check — internal, must not log.
if (registry.getProject(opts.name, 'standard', { logInteraction: false })) {
throw new RegistryError(
'DUPLICATE',
`A project named '${opts.name}' is already registered.`,
Expand Down
55 changes: 51 additions & 4 deletions packages/core/src/cross-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { homedir } from 'node:os';
import type Database from 'better-sqlite3';
import { connect, getDbPath, initDb } from './db.js';
import { MemoryRetrieval, type RecallResult } from './memory-retrieval.js';
import { logInteraction } from './interactions.js';

interface CrossQueryResult {
source: 'registry' | 'memory' | 'cc_memory';
Expand Down Expand Up @@ -53,10 +54,44 @@ export class CrossQuery {
// Score and rank
results = this.rankResults(results, opts.query);

// Spec 0.34 (#portfolio-memory 2.12, S204): cross_query matching N
// projects produces N rows — one per matched project. Failed (zero
// matches) calls produce a single row with project_id=NULL.
this.logCrossQueryInteractions(opts.query, results);

const summary = this.synthesize(results, opts.query);
return { results, summary };
}

/**
* Insert one interactions row per matched project (or a single NULL row
* when no matches). Best-effort — failure to log is swallowed by
* logInteraction itself.
*/
private logCrossQueryInteractions(query: string, results: CrossQueryResult[]): void {
const db = this.open();
try {
const projectNames = new Set<string>();
for (const r of results) {
if (r.source === 'registry' && r.project) projectNames.add(r.project);
}
if (projectNames.size === 0) {
logInteraction(db, { surface: 'cross_query', projectId: null, query });
return;
}
for (const name of projectNames) {
const row = db.prepare('SELECT id FROM projects WHERE name = ?').get(name) as { id: number } | undefined;
logInteraction(db, {
surface: 'cross_query',
projectId: row?.id ?? null,
query,
});
}
} finally {
db.close();
}
}

private searchRegistry(query: string): CrossQueryResult[] {
const db = this.open();
try {
Expand Down Expand Up @@ -169,22 +204,32 @@ export class CrossQuery {
}

portfolioBrief(): {
projects: { name: string; type: string; status: string; spec_version?: string; updated_at: string }[];
projects: { name: string; type: string; status: string; spec_version?: string; updated_at: string; last_touched?: string | null; recent_activity_count?: number }[];
portfolio_memories: RecallResult[];
pending_observations: RecallResult[];
health_indicators: { project: string; issue: string }[];
enrichment_gaps: { project: string; missing: string[] }[];
} {
const db = this.open();
try {
// Active projects with basic identity
// Spec 0.34 (#portfolio-memory 2.12, S205): derived recency and frequency
// join through MAX(at) and COUNT(*) on the interactions table. Never
// stored, recomputed per call. Joins are LEFT so projects with zero
// interactions still appear with null/0 signal.
const projects = db.prepare(`
SELECT p.name, p.type, p.status, p.updated_at,
(SELECT pf.field_value FROM project_fields pf WHERE pf.project_id = p.id AND pf.field_name = 'spec_version') as spec_version
(SELECT pf.field_value FROM project_fields pf
WHERE pf.project_id = p.id AND pf.field_name = 'spec_version') as spec_version,
(SELECT MAX(i.at) FROM interactions i WHERE i.project_id = p.id) as last_touched,
(SELECT COUNT(*) FROM interactions i
WHERE i.project_id = p.id AND i.at > datetime('now', '-7 days')) as recent_activity_count
FROM projects p
WHERE p.status NOT IN ('archived')
ORDER BY p.name
`).all() as { name: string; type: string; status: string; updated_at: string; spec_version: string | null }[];
`).all() as {
name: string; type: string; status: string; updated_at: string;
spec_version: string | null; last_touched: string | null; recent_activity_count: number | null;
}[];

// Portfolio-scoped and global memories via recall (bootstrap mode)
const retrieval = new MemoryRetrieval(this._dbPath);
Expand Down Expand Up @@ -289,6 +334,8 @@ export class CrossQuery {
status: p.status,
spec_version: p.spec_version ?? undefined,
updated_at: p.updated_at,
last_touched: p.last_touched,
recent_activity_count: p.recent_activity_count ?? 0,
})),
portfolio_memories: portfolioMemories,
pending_observations: pendingObservations,
Expand Down
Loading
Loading