@prjct.app/pi-signals 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default, installSignals, type SignalsDependencies } from './src/index.ts';
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@prjct.app/pi-signals",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Local failure signals, daily agent-owned triage and verified repair candidates for Pi.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=22.19.0"
9
+ },
10
+ "keywords": [
11
+ "pi-package",
12
+ "pi-extension",
13
+ "signals",
14
+ "triage",
15
+ "quality"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "pi": {
21
+ "extensions": [
22
+ "./index.ts"
23
+ ]
24
+ },
25
+ "files": [
26
+ "index.ts",
27
+ "src",
28
+ "README.md",
29
+ "LICENSE",
30
+ "CHANGELOG.md",
31
+ "dist"
32
+ ],
33
+ "dependencies": {
34
+ "@prjct.app/pi-secrets": "^0.2.6"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-ai": ">=1.0.4",
38
+ "@earendil-works/pi-coding-agent": ">=1.0.4",
39
+ "typebox": "*"
40
+ },
41
+ "devDependencies": {
42
+ "@earendil-works/pi-ai": "1.0.4",
43
+ "@earendil-works/pi-coding-agent": "1.0.4",
44
+ "@types/node": "^22.19.0",
45
+ "esbuild": "^0.28.2",
46
+ "tsx": "^4.20.0",
47
+ "typebox": "1.3.7",
48
+ "typescript": "^5.9.3"
49
+ },
50
+ "scripts": {
51
+ "check": "tsc --noEmit",
52
+ "test": "node --import ./tests/isolate.mjs --import tsx --test --test-concurrency=2 tests/*.test.ts",
53
+ "check:package": "npm pack --dry-run --ignore-scripts",
54
+ "build": "node scripts/build.mjs",
55
+ "prepack": "npm run build"
56
+ },
57
+ "bin": {
58
+ "pi-signals": "dist/cli.mjs"
59
+ }
60
+ }
package/src/agent.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { join } from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type ToolDefinition } from '@earendil-works/pi-coding-agent';
4
+ import { protectOutboundData } from '@prjct.app/pi-secrets/privacy';
5
+ import { config, stateDir } from './config.ts';
6
+ import type { SignalModel } from './schema.ts';
7
+
8
+ export type AgentTask = { cwd: string; model?: SignalModel; prompt: string; tools: ToolDefinition[]; writable: boolean; signal?: AbortSignal };
9
+ export type AgentRunner = (task: AgentTask) => Promise<void>;
10
+ export const runAgent: AgentRunner = async task => {
11
+ const home = getAgentDir();
12
+ const configured = SettingsManager.create(task.cwd, home, { projectTrusted: false });
13
+ if (configured.drainErrors().length) throw new Error('Cannot read Pi settings for the signals worker.');
14
+ const effective = configured.getSettings();
15
+ const selected = task.model ?? (effective.defaultProvider && effective.defaultModel ? { provider: effective.defaultProvider, id: effective.defaultModel, thinkingLevel: effective.defaultThinkingLevel } : undefined);
16
+ if (!selected) throw new Error('No originating or configured Pi model is available. Select a model in Pi.');
17
+ const runtime = await ModelRuntime.create({ authPath: join(home, 'auth.json'), modelsPath: join(home, 'models.json'), allowModelNetwork: false });
18
+ const model = runtime.getModel(selected.provider, selected.id);
19
+ const manager = new DefaultPackageManager({ cwd: task.cwd, agentDir: home, settingsManager: configured });
20
+ const admitted = config().workerPackages;
21
+ const paths = manager.listConfiguredPackages().flatMap(pkg => {
22
+ if (!pkg.installedPath) return [];
23
+ const manifest = JSON.parse(readFileSync(join(pkg.installedPath, 'package.json'), 'utf8'));
24
+ return admitted.includes(manifest.name) ? (manifest.pi?.extensions ?? []).map((entry: string) => join(pkg.installedPath!, entry)) : [];
25
+ });
26
+ const settings = SettingsManager.inMemory({ ...effective, packages: [], extensions: [], cacheWarming: 'off' });
27
+ const loader = new DefaultResourceLoader({ cwd: task.cwd, agentDir: home, settingsManager: settings,
28
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, additionalExtensionPaths: paths });
29
+ await loader.reload();
30
+ if (loader.getExtensions().errors.length) throw new Error('The worker could not load the admitted Pi extensions.');
31
+ const { session } = await createAgentSession({ cwd: task.cwd, agentDir: home, settingsManager: settings, resourceLoader: loader,
32
+ modelRuntime: runtime, model, thinkingLevel: selected.thinkingLevel, sessionManager: SessionManager.create(task.cwd, join(stateDir(), 'sessions')),
33
+ tools: ['read', 'grep', 'find', 'ls', 'bash', ...(task.writable ? ['edit', 'write'] : []), 'context_status', 'context_history', 'context_compact', ...task.tools.map(tool => tool.name)], customTools: task.tools });
34
+ const abort = () => { void session.abort(); };
35
+ task.signal?.addEventListener('abort', abort, { once: true });
36
+ try {
37
+ task.signal?.throwIfAborted();
38
+ await session.bindExtensions({ mode: 'rpc' });
39
+ const resolved = runtime.getModel(selected.provider, selected.id);
40
+ if (!resolved) throw new Error(`The originating model ${selected.provider}/${selected.id} is unavailable; no substitute was selected.`);
41
+ await session.setModel(resolved);
42
+ if (selected.thinkingLevel) session.setThinkingLevel(selected.thinkingLevel);
43
+ await session.prompt(await protectOutboundData(task.prompt));
44
+ task.signal?.throwIfAborted();
45
+ } finally {
46
+ task.signal?.removeEventListener('abort', abort);
47
+ await session.extensionRunner?.emit({ type: 'session_shutdown', reason: 'quit' });
48
+ session.dispose();
49
+ }
50
+ };
package/src/cli.ts ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url';
3
+ import { databasePath, config } from './config.ts';
4
+ import { SignalStore } from './store.ts';
5
+
6
+ async function main() {
7
+ const [action = 'status', argument] = process.argv.slice(2);
8
+ if (action === 'schedule' && argument === 'install') {
9
+ return (await import('./scheduler.ts')).installSchedule(fileURLToPath(import.meta.url));
10
+ }
11
+ if (action === 'once') {
12
+ const controller = new AbortController();
13
+ const abort = () => controller.abort(new Error('Signals worker cancelled'));
14
+ process.once('SIGTERM', abort); process.once('SIGINT', abort);
15
+ try { return await (await import('./worker.ts')).runDaily(argument === '--scheduled', controller.signal); }
16
+ finally { process.removeListener('SIGTERM', abort); process.removeListener('SIGINT', abort); }
17
+ }
18
+ const store = new SignalStore(databasePath());
19
+ try {
20
+ if (action === 'status') { const issues = store.list(); return { config: config(), issues: issues.slice(0, 20), nextOffset: issues.length > 20 ? 20 : null }; }
21
+ if (action === 'retry' && argument && /^[a-f0-9]{64}$/.test(argument)) return { queued: store.retry(argument) };
22
+ if (action === 'show' && argument) return { issue: store.get(argument), observations: store.observations(argument), runs: store.runs(argument) };
23
+ throw new Error('Usage: pi-signals status | show <id> | retry <id> | once [--scheduled] | schedule install');
24
+ } finally { store.close(); }
25
+ }
26
+ try { console.log(JSON.stringify(await main(), null, 2)); }
27
+ catch (error) {
28
+ const { protectOutboundData } = await import('@prjct.app/pi-secrets/privacy');
29
+ console.error(await protectOutboundData(String(error)).catch(() => 'Signals failed; outbound privacy protection was unavailable.'));
30
+ process.exitCode = 1;
31
+ }
@@ -0,0 +1,16 @@
1
+ import { createLocalBashOperations } from '@earendil-works/pi-coding-agent';
2
+
3
+ export type Check = { command: string; code: number; output: string; durationMs: number };
4
+ export const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
5
+ /** Use Pi's public execution/cancellation backend, including process-tree cleanup. */
6
+ export async function command(cwd: string, value: string | string[], signal?: AbortSignal): Promise<Check> {
7
+ const text = Array.isArray(value) ? value.map(quote).join(' ') : value;
8
+ const chunks: Buffer[] = [], start = performance.now();
9
+ const result = await createLocalBashOperations().exec(text, cwd, { onData: data => chunks.push(data), signal, timeout: 600 });
10
+ return { command: text, code: result.exitCode ?? 128, output: Buffer.concat(chunks).toString('utf8'), durationMs: performance.now() - start };
11
+ }
12
+ export async function must(cwd: string, value: string | string[], signal?: AbortSignal) {
13
+ const result = await command(cwd, value, signal);
14
+ if (result.code !== 0) throw new Error(`Command failed (${result.code}): ${result.command}\n${result.output}`);
15
+ return result.output.trim();
16
+ }
package/src/config.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import { DefaultPackageManager, getAgentDir, getPackageDir, SettingsManager, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
5
+ import type { Target } from './schema.ts';
6
+
7
+ export const stateDir = () => join(process.env.PRJCT_HOME ?? join(homedir(), '.prjct'), 'shared', 'signals');
8
+ export const databasePath = () => join(stateDir(), 'signals.sqlite');
9
+ export type Repository = { path: string; base: string; setup: string[]; tests: string[] };
10
+ export type Config = { enabled: boolean; packages: string[]; workerPackages: string[]; repositories: Record<string, Repository>; timezone: string; hour: number; minute: number; repair: boolean; delivery: 'local' };
11
+ export function config(): Config {
12
+ const defaults: Config = { enabled: true, packages: ['@prjct.app/'], workerPackages: ['@prjct.app/pi-secrets', '@prjct.app/pi-context-control', '@prjct.app/pi-self-compact'], repositories: {}, timezone: 'America/Cancun', hour: 9, minute: 0, repair: false, delivery: 'local' };
13
+ try {
14
+ const raw = JSON.parse(readFileSync(join(stateDir(), 'config.json'), 'utf8'));
15
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Expected an object');
16
+ const value: Config = { ...defaults, ...raw };
17
+ if (typeof value.enabled !== 'boolean' || typeof value.repair !== 'boolean' || value.delivery !== 'local'
18
+ || !Array.isArray(value.packages) || !value.packages.every(prefix => typeof prefix === 'string' && prefix.length > 0)
19
+ || !Array.isArray(value.workerPackages) || !value.workerPackages.every(name => typeof name === 'string' && name.length > 0)
20
+ || !Number.isInteger(value.hour) || value.hour < 0 || value.hour > 23 || !Number.isInteger(value.minute) || value.minute < 0 || value.minute > 59
21
+ || !value.repositories || typeof value.repositories !== 'object' || Array.isArray(value.repositories)) throw new Error('Invalid configuration');
22
+ new Intl.DateTimeFormat('en', { timeZone: value.timezone });
23
+ for (const repo of Object.values(value.repositories)) {
24
+ if (!repo || !isAbsolute(repo.path) || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(repo.base) || repo.base.includes('..')
25
+ || !Array.isArray(repo.setup) || !Array.isArray(repo.tests) || ![...repo.setup, ...repo.tests].every(command => typeof command === 'string' && command.trim())) throw new Error('Invalid repository binding');
26
+ }
27
+ return value;
28
+ } catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaults;
30
+ throw new Error('Invalid global signals configuration; processing is disabled until repaired.');
31
+ }
32
+ }
33
+ function manifestTarget(root: string): Target | undefined {
34
+ try {
35
+ const value = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
36
+ return typeof value.name === 'string' && typeof value.version === 'string' ? { id: value.name, version: value.version } : undefined;
37
+ } catch { return undefined; }
38
+ }
39
+ export function targets(pi: ExtensionAPI, ctx: ExtensionContext, policy: Config): Target[] {
40
+ const effective = pi.getSettings();
41
+ const settings = effective.packages?.length ? SettingsManager.inMemory(effective) : SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted: false });
42
+ if (settings.drainErrors().some(error => error.scope === 'global')) throw new Error('Cannot read the global Pi package catalog.');
43
+ const manager = new DefaultPackageManager({ cwd: ctx.cwd, agentDir: getAgentDir(), settingsManager: settings });
44
+ const installed = manager.listConfiguredPackages().flatMap(pkg => pkg.installedPath ? [manifestTarget(pkg.installedPath)].filter((value): value is Target => !!value) : [])
45
+ .filter(target => policy.packages.some(prefix => target.id === prefix || (prefix.endsWith('/') && target.id.startsWith(prefix))));
46
+ const core = manifestTarget(getPackageDir());
47
+ return [...new Map([...installed, ...(core ? [{ ...core, id: 'pi' }] : [])].map(target => [target.id, target])).values()];
48
+ }
@@ -0,0 +1,25 @@
1
+ import { Type } from 'typebox';
2
+ import type { ToolDefinition } from '@earendil-works/pi-coding-agent';
3
+ import { protectOutboundData } from '@prjct.app/pi-secrets/privacy';
4
+
5
+ export type EvidenceSource = readonly unknown[] | ((offset: number, limit: number) => unknown[]);
6
+ /** Keep full observations in storage; the deciding agent chooses which pages to read. */
7
+ export function evidenceTool(source: EvidenceSource) {
8
+ const seen: string[] = [];
9
+ const tool: ToolDefinition = {
10
+ name: 'signal_evidence', label: 'Read original signal evidence',
11
+ description: 'Read original issue observations by page. Nothing is discarded; follow nextOffset to inspect all sightings. Select a smaller limit for large reports.',
12
+ parameters: Type.Object({ offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })) }),
13
+ async execute(_id, raw) {
14
+ const input = raw as { offset?: number; limit?: number }, offset = input.offset ?? 0, limit = input.limit ?? 1;
15
+ const rows = typeof source === 'function' ? source(offset, limit + 1) : source.slice(offset, offset + limit + 1);
16
+ const items = await protectOutboundData(rows.slice(0, limit));
17
+ for (const item of items) {
18
+ const payload = (item as { payload?: { body?: string } }).payload;
19
+ seen.push(payload?.body ?? JSON.stringify(item));
20
+ }
21
+ return { content: [{ type: 'text', text: JSON.stringify({ items, nextOffset: rows.length > limit ? offset + limit : null }) }], details: {} };
22
+ },
23
+ };
24
+ return { tool, contains: (quote: string) => seen.some(text => text.includes(quote)) };
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { protectOutboundData } from '@prjct.app/pi-secrets/privacy';
3
+ import { config, targets, databasePath } from './config.ts';
4
+ import { hideLocalPaths, prepareReport } from './report.ts';
5
+ import { InspectSchema, OBSERVATION, ReportSchema } from './schema.ts';
6
+ import { SignalStore } from './store.ts';
7
+
8
+ const defaults = { config, targets, protect: protectOutboundData, databasePath };
9
+ export type SignalsDependencies = Partial<typeof defaults>;
10
+ const result = (value: unknown) => ({ content: [{ type: 'text' as const, text: JSON.stringify(value) }], details: {} });
11
+ const page = <T>(rows: T[], offset: number) => ({ items: rows.slice(0, 20), nextOffset: rows.length > 20 ? offset + 20 : null });
12
+ export function installSignals(pi: ExtensionAPI, dependencies: SignalsDependencies = {}) {
13
+ const deps = { ...defaults, ...dependencies };
14
+ const catalog = (ctx: ExtensionContext) => {
15
+ const settings = deps.config();
16
+ if (!settings.enabled) throw new Error('Signals is disabled by global configuration.');
17
+ return deps.targets(pi, ctx, settings);
18
+ };
19
+ pi.registerTool({
20
+ name: 'signal_report', label: 'Record failure signal',
21
+ description: 'Record a local issue in the global .prjct database for an installed extension or Pi: latency regression, crash, or concrete response-quality regression. Supply a stable defect signature, exact session evidence, reproduction steps, expected and actual behavior. No GitHub or model request occurs during capture.',
22
+ promptGuidelines: ['When you detect a latency regression, crash, or concrete response-quality regression in Pi or an installed extension, record minimal evidence with signal_report, then continue the original task. Use signal_inspect for targets. Detection is a signal, not proof of causality; the active agent and later triage decide.'],
23
+ parameters: ReportSchema,
24
+ async execute(_id, input, _signal, _update, ctx) {
25
+ const target = catalog(ctx).find(target => target.id === input.target);
26
+ if (!target) throw new Error('Unknown component. Inspect targets before recording a signal.');
27
+ const capture = prepareReport(input, target, ctx);
28
+ const payload = await deps.protect(hideLocalPaths(capture.payload, ctx.cwd));
29
+ const store = new SignalStore(deps.databasePath());
30
+ try { return result(store.capture({ ...capture, payload })); } finally { store.close(); }
31
+ },
32
+ });
33
+ pi.registerTool({
34
+ name: 'signal_inspect', label: 'Inspect failure signals',
35
+ description: 'Read targets, local issues, or observations. For stored issue observations supply issueId; otherwise observations are native failures on this session branch. Paginated; no external calls.',
36
+ parameters: InspectSchema,
37
+ async execute(_id, input, _signal, _update, ctx) {
38
+ const allowed = catalog(ctx), offset = input.offset ?? 0;
39
+ if (input.action === 'targets') return result(page(allowed.slice(offset, offset + 21), offset));
40
+ if (input.action === 'observations' && !input.issueId) {
41
+ const entries = ctx.sessionManager.getBranch().filter(entry => entry.type === 'custom' && entry.customType === OBSERVATION).reverse();
42
+ return result(page(entries.slice(offset, offset + 21).map(entry => ({ entryId: entry.id, data: entry.type === 'custom' ? entry.data : undefined })), offset));
43
+ }
44
+ const store = new SignalStore(deps.databasePath());
45
+ try { return result(page<unknown>(input.action === 'issues' ? store.list(offset) : store.observations(input.issueId!, offset), offset)); }
46
+ finally { store.close(); }
47
+ },
48
+ });
49
+ pi.registerCommand('signals', {
50
+ description: 'Show local issues and daily triage configuration',
51
+ async handler(_args, ctx) {
52
+ try {
53
+ const settings = deps.config(), store = new SignalStore(deps.databasePath());
54
+ try { ctx.ui.notify(JSON.stringify({ enabled: settings.enabled, daily: `${settings.hour}:${String(settings.minute).padStart(2, '0')} ${settings.timezone}`, issues: page(store.list(), 0) }), 'info'); }
55
+ finally { store.close(); }
56
+ } catch { ctx.ui.notify('Cannot read signals configuration or database.', 'error'); }
57
+ },
58
+ });
59
+ pi.on('session_compact_failed', async (event, ctx) => {
60
+ if (event.aborted) return;
61
+ try {
62
+ if (!deps.config().enabled) return;
63
+ pi.appendEntry(OBSERVATION, await deps.protect(hideLocalPaths({ reason: event.reason, error: event.errorMessage ?? 'Compaction failed',
64
+ fromExtension: event.fromExtension, willRetry: event.willRetry, time: Date.now() }, ctx.cwd)));
65
+ } catch { /* A failed observer never interrupts recovery or stores unprotected evidence. */ }
66
+ });
67
+ }
68
+ export default installSignals;
package/src/repair.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { Type, type Static } from 'typebox';
2
+ import { copyFile, lstat, mkdir } from 'node:fs/promises';
3
+ import { dirname, join, resolve, sep } from 'node:path';
4
+ import { protectOutboundData } from '@prjct.app/pi-secrets/privacy';
5
+ import { runAgent, type AgentRunner } from './agent.ts';
6
+ import { command, must, type Check } from './commands.ts';
7
+ import { stateDir, type Repository } from './config.ts';
8
+ import type { LocalIssue } from './store.ts';
9
+ import { evidenceTool, type EvidenceSource } from './evidence.ts';
10
+
11
+ const FixSchema = Type.Object({
12
+ explanation: Type.String({ minLength: 1 }),
13
+ testFiles: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
14
+ regressionCommand: Type.String({ minLength: 1 }),
15
+ failurePattern: Type.String({ minLength: 8 }),
16
+ }, { additionalProperties: false });
17
+ type Fix = Static<typeof FixSchema>;
18
+ const ReviewSchema = Type.Object({ fixesSignal: Type.Boolean(), testsMeaningful: Type.Boolean(), explanation: Type.String({ minLength: 1 }) });
19
+ export type RepairResult = { status: 'verified' | 'blocked'; explanation: string; base: string; head?: string; branch?: string; path: string; checks: Check[]; review?: unknown };
20
+
21
+ /** A regression test is replayed unchanged on the base and candidate, then the trusted suite runs. */
22
+ export async function repair(issue: LocalIssue, repo: Repository, path: string, base: string, observations: EvidenceSource, signal?: AbortSignal, agent: AgentRunner = runAgent): Promise<RepairResult> {
23
+ const state: { fix?: Fix; review?: Static<typeof ReviewSchema> } = {};
24
+ const checks: Check[] = [];
25
+ const blocked = (explanation: string): RepairResult => ({ status: 'blocked', explanation, base, path, checks });
26
+ await agent({ cwd: path, model: issue.model, writable: true, signal,
27
+ prompt: `Repair signal ${issue.id} in this isolated worktree. Preserve the task, public Pi SDK architecture and model autonomy. No JEV, classifier substitution, hidden context pruning, or cost-based model changes. Keep the fix small and reuse existing code. Do not publish, push, merge, or commit; the coordinator owns release gates.\n`
28
+ + `Add a meaningful regression test reproducing the exact observed failure. The SAME new test files will be copied to unchanged base ${base} and must fail there with the specific failurePattern; on your fix they must pass repeatedly. Do not edit existing tests or weaken assertions merely to make the suite pass. For latency use comparable repeated measurements; for response quality use the original task constraints and a controlled expected response, not general intelligence claims.\n`
29
+ + `Read the original observations with signal_evidence. Use signal_fix to describe the fix and name only new test files (under tests/test/__tests__ or *.test.*/*.spec.*). Full suites: ${JSON.stringify(repo.tests)}.\n${JSON.stringify({ issue })}`,
30
+ tools: [evidenceTool(observations).tool, { name: 'signal_fix', label: 'Submit repair candidate', description: 'Submit the candidate for independent baseline, regression, suite and semantic checks. This does not mark it fixed.', parameters: FixSchema,
31
+ async execute(_id, input) { state.fix = input as Fix; return { content: [{ type: 'text', text: 'Candidate recorded for verification.' }], details: {} }; } }],
32
+ });
33
+ if (!state.fix) return blocked('No structured repair candidate was provided.');
34
+ if (!repo.tests.length) return blocked('No trusted package validation commands are configured.');
35
+ const fix = state.fix;
36
+ const baselinePath = join(stateDir(), 'worktrees', `baseline-${issue.id.slice(0, 12)}-${Date.now()}`);
37
+ await must(repo.path, ['git', 'worktree', 'add', '--detach', baselinePath, base], signal);
38
+ for (const setup of repo.setup) await must(baselinePath, setup, signal);
39
+ for (const file of fix.testFiles) {
40
+ if (file.startsWith('/') || file.split(/[\\/]/).some(part => part === '..' || part === '.git') || !/(^|\/)(__tests__|tests?)\/|\.(test|spec)\./.test(file)) return blocked('Regression files must be new tests within the worktree.');
41
+ const source = resolve(path, file), destination = resolve(baselinePath, file);
42
+ if (!source.startsWith(path + sep) || !destination.startsWith(baselinePath + sep)) return blocked('Regression file escaped the worktree.');
43
+ // Refuse symlinks throughout either path; do not copy arbitrary external files.
44
+ for (const root of [path, baselinePath]) {
45
+ let current = root;
46
+ for (const part of file.split('/')) {
47
+ current = join(current, part);
48
+ const stat = await lstat(current).catch(() => undefined);
49
+ if (stat?.isSymbolicLink()) return blocked('Regression paths must not contain symlinks.');
50
+ }
51
+ }
52
+ if ((await command(path, ['git', 'cat-file', '-e', `${base}:${file}`], signal)).code === 0) return blocked('Use a new regression test file so baseline assertions cannot be silently rewritten.');
53
+ await mkdir(dirname(destination), { recursive: true }); await copyFile(source, destination);
54
+ }
55
+ const files = [...new Set((await must(path, ['git', 'diff', '--name-only', '-z'], signal) + '\0' + await must(path, ['git', 'ls-files', '--others', '--exclude-standard', '-z'], signal)).split('\0').filter(Boolean))];
56
+ if (!files.length) return blocked('No changed files in the repair candidate.');
57
+ const branch = `signals/${issue.id.slice(0, 12)}-${Date.now()}`;
58
+ await must(path, ['git', 'switch', '-c', branch], signal);
59
+ await must(path, ['git', 'add', '--', ...files], signal);
60
+ const diff = await must(path, ['git', 'diff', '--cached', '--binary'], signal);
61
+ if (await protectOutboundData(diff) !== diff) return blocked('The candidate diff contains data caught by outbound protection; publication is blocked.');
62
+ await must(path, ['git', 'commit', '-m', `fix: ${issue.title}`], signal);
63
+ const head = await must(path, ['git', 'rev-parse', 'HEAD'], signal);
64
+ for (let sample = 0; sample < 3; sample++) {
65
+ const before = await command(baselinePath, fix.regressionCommand, signal); checks.push(before);
66
+ if (before.code === 0 || !before.output.includes(fix.failurePattern)) return blocked('The unchanged baseline did not consistently reproduce the claimed failure.');
67
+ const after = await command(path, fix.regressionCommand, signal); checks.push(after);
68
+ if (after.code !== 0) return blocked('The regression test still fails or is intermittent on the candidate.');
69
+ }
70
+ for (const test of repo.tests) {
71
+ const check = await command(path, test, signal); checks.push(check);
72
+ if (check.code !== 0) return blocked('A required package validation failed.');
73
+ }
74
+ await agent({ cwd: path, model: issue.model, writable: false, signal,
75
+ prompt: `Review repair ${head} against base ${base} for signal ${issue.id}. You use the same model and reasoning as the originating agent. Inspect git diff ${base}..${head}, the new regression tests and these measured checks. Use signal_evidence for original observations and signal_validation for full check output by index. Passing tests alone is insufficient: decide whether the change addresses the actual reported failure, whether the assertion is meaningful, and whether latency/quality comparisons have controlled baselines. Reject weakened tests, output suppression, unrelated changes and context/model degradation. Do not modify files or publish. Record signal_review, or leave the candidate blocked.\n${JSON.stringify(await protectOutboundData({ issue, fix, checks: checks.map(({ command, code, durationMs }, index) => ({ index, command, code, durationMs })) }))}`,
76
+ tools: [evidenceTool(observations).tool, { name: 'signal_validation', label: 'Read validation output', description: 'Read the complete recorded output of one check by its index.', parameters: Type.Object({ index: Type.Integer({ minimum: 0 }) }),
77
+ async execute(_id, raw) { const check = checks[(raw as { index: number }).index]; if (!check) throw new Error('Unknown validation index.'); return { content: [{ type: 'text', text: JSON.stringify(await protectOutboundData(check)) }], details: {} }; } },
78
+ { name: 'signal_review', label: 'Review regression repair', description: 'Record the semantic review of the exact tested candidate.', parameters: ReviewSchema,
79
+ async execute(_id, input) { state.review = input as Static<typeof ReviewSchema>; return { content: [{ type: 'text', text: 'Review recorded.' }], details: {} }; } }],
80
+ });
81
+ if (!state.review?.fixesSignal || !state.review.testsMeaningful) return blocked(state.review?.explanation ?? 'No evidence-backed review approval.');
82
+ if (await must(path, ['git', 'rev-parse', 'HEAD'], signal) !== head || await must(path, ['git', 'status', '--porcelain'], signal)) return blocked('The candidate changed after verification.');
83
+ if (await must(repo.path, ['git', 'rev-parse', `${repo.base}^{commit}`], signal) !== base) return blocked('The base advanced; revalidation is required before merge.');
84
+ return { status: 'verified', explanation: state.review.explanation, base, head, branch, path, checks: await protectOutboundData(checks), review: await protectOutboundData(state.review) };
85
+ }
package/src/report.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { homedir } from 'node:os';
3
+ import { contentText } from '@earendil-works/pi-ai';
4
+ import { VERSION, type ExtensionContext, type SessionEntry } from '@earendil-works/pi-coding-agent';
5
+ import { OBSERVATION, type Capture, type ReportInput, type Target } from './schema.ts';
6
+
7
+ export const entryText = (entry: SessionEntry): string => {
8
+ if (entry.type === 'message' && 'content' in entry.message) return contentText(entry.message.content);
9
+ if (entry.type === 'custom_message') return contentText(entry.content);
10
+ if (entry.type === 'custom' && entry.customType === OBSERVATION) return JSON.stringify(entry.data);
11
+ return '';
12
+ };
13
+
14
+ export function prepareReport(input: ReportInput, target: Target, ctx: ExtensionContext): Capture {
15
+ if (!input.title.trim() || !input.signature.trim() || !input.expected.trim() || !input.actual.trim()
16
+ || !input.steps.length || input.steps.some(step => !step.trim()) || !input.evidence.length) throw new Error('Include a title, stable defect signature, reproduction steps, expected and actual results, and evidence.');
17
+ const branch = ctx.sessionManager.getBranch();
18
+ const evidence = input.evidence.map(item => {
19
+ const entry = branch.findLast(entry => (!item.entryId || entry.id === item.entryId) && entryText(entry).includes(item.quote));
20
+ if (!entry) throw new Error('Evidence must quote an original entry on this session branch. Inspect observations or use context_history to recover the exact text.');
21
+ const source = entry.type === 'message' ? entry.message.role : entry.type;
22
+ return { source, quote: item.quote, entryId: entry.id };
23
+ });
24
+ if (input.reproduced && !evidence.some(item => item.source === 'toolResult' || item.source === 'custom')) throw new Error('A reproduced defect needs tool or native SDK evidence; a claim in prose alone is not execution evidence.');
25
+ const signature = input.signature.trim().toLowerCase().replace(/\s+/g, ' ');
26
+ const hash = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
27
+ const id = hash([target.id, signature]);
28
+ const observationId = hash([id, ctx.sessionManager.getSessionId(), evidence.map(item => item.entryId), evidence.map(item => item.quote)]);
29
+ const quote = (value: string) => value.split('\n').map(line => `> ${line}`).join('\n');
30
+ const body = [
31
+ `## Observed problem\n\n${input.actual}`,
32
+ `## Expected behavior\n\n${input.expected}`,
33
+ `## Reproduction\n\nStatus: ${input.reproduced ? 'reproduced by the reporting agent' : 'observed; reproduction not independently confirmed'}.\n\n${input.steps.map((step, i) => `${i + 1}. ${step}`).join('\n')}`,
34
+ `## Evidence\n\n${evidence.map(item => `Source: ${item.source}\n\n${quote(item.quote)}`).join('\n\n')}`,
35
+ `## Environment\n\n- Component: ${target.id}@${target.version}\n- Pi: ${VERSION}\n- Model: ${ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : 'unavailable'}\n- Reasoning: ${ctx.thinkingLevel ?? 'unspecified'}\n- Node: ${process.version}\n- Platform: ${process.platform}/${process.arch}\n- Category: ${input.category}`,
36
+ input.measurements ? `## Measurements\n\n${JSON.stringify(input.measurements)}` : '',
37
+ 'Recorded by the active Pi agent from selected evidence. Triage must evaluate causality and reproducibility; this signal does not establish a general change in model intelligence.',
38
+ ].filter(Boolean).join('\n\n');
39
+ return { id, observationId, component: target.id, version: target.version, category: input.category, payload: { title: input.title.trim(), body },
40
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, thinkingLevel: ctx.thinkingLevel } : undefined };
41
+ }
42
+
43
+ /** Publication uses a minimal report, never a copied transcript or automatic file attachments. */
44
+ export function hideLocalPaths<T>(value: T, cwd: string): T {
45
+ const serialized = JSON.stringify(value);
46
+ const paths = new Map([[homedir(), '<home>'], [cwd, '<workspace>']]);
47
+ const protectedText = [...paths].filter(([path]) => path.length > 1).sort(([a], [b]) => b.length - a.length)
48
+ .reduce((text, [path, label]) => text.replaceAll(JSON.stringify(path).slice(1, -1), label), serialized);
49
+ return JSON.parse(protectedText) as T;
50
+ }
@@ -0,0 +1,35 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join, isAbsolute } from 'node:path';
4
+ import { getAgentDir } from '@earendil-works/pi-coding-agent';
5
+ import { command, must } from './commands.ts';
6
+ import { config, stateDir } from './config.ts';
7
+
8
+ const label = 'app.prjct.pi-signals';
9
+ const xml = (value: string) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
10
+ export function launchAgent(entry: string) {
11
+ if (!isAbsolute(entry)) throw new Error('Scheduler requires the absolute installed CLI path.');
12
+ const settings = config();
13
+ const variables = { PATH: process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin', PI_CODING_AGENT_DIR: getAgentDir(),
14
+ ...(process.env.PRJCT_HOME ? { PRJCT_HOME: process.env.PRJCT_HOME } : {}) };
15
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>
16
+ <key>Label</key><string>${label}</string>
17
+ <key>ProgramArguments</key><array>${[process.execPath, entry, 'once', '--scheduled'].map(value => `<string>${xml(value)}</string>`).join('')}</array>
18
+ <key>EnvironmentVariables</key><dict>${Object.entries(variables).map(([key, value]) => `<key>${key}</key><string>${xml(value)}</string>`).join('')}</dict>
19
+ <key>RunAtLoad</key><true/>
20
+ <key>StartCalendarInterval</key><dict><key>Minute</key><integer>${settings.minute}</integer></dict>
21
+ <key>StandardOutPath</key><string>${xml(join(stateDir(), 'worker.log'))}</string>
22
+ <key>StandardErrorPath</key><string>${xml(join(stateDir(), 'worker-error.log'))}</string>
23
+ <key>ProcessType</key><string>Background</string>
24
+ </dict></plist>\n`;
25
+ }
26
+ export async function installSchedule(entry: string) {
27
+ if (process.platform !== 'darwin') throw new Error('Use an OS scheduler to run pi-signals once --scheduled hourly on this platform.');
28
+ const directory = join(homedir(), 'Library', 'LaunchAgents'), file = join(directory, `${label}.plist`);
29
+ await mkdir(directory, { recursive: true }); await mkdir(stateDir(), { recursive: true, mode: 0o700 });
30
+ await writeFile(file, launchAgent(entry), { mode: 0o600 });
31
+ const domain = `gui/${process.getuid!()}`;
32
+ const loaded = await command(stateDir(), ['launchctl', 'print', `${domain}/${label}`]);
33
+ if (loaded.code !== 0) await must(stateDir(), ['launchctl', 'bootstrap', domain, file]);
34
+ return { installed: true, file, schedule: config().timezone, existingProcessesStopped: false };
35
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { Type, type Static } from 'typebox';
2
+
3
+ const text = (maxLength: number) => Type.String({ minLength: 1, maxLength });
4
+ export const ReportSchema = Type.Object({
5
+ target: text(150),
6
+ title: text(160),
7
+ signature: text(240),
8
+ category: Type.Union([Type.Literal('latency'), Type.Literal('crash'), Type.Literal('response-quality')]),
9
+ steps: Type.Array(text(2000), { minItems: 1, maxItems: 20 }),
10
+ reproduced: Type.Boolean(),
11
+ expected: text(4000),
12
+ actual: text(4000),
13
+ evidence: Type.Array(Type.Object({ quote: Type.String({ minLength: 4, maxLength: 2000 }), entryId: Type.Optional(text(150)) }), { minItems: 1, maxItems: 8 }),
14
+ measurements: Type.Optional(Type.Object({ metric: text(150), unit: text(40), baseline: Type.Array(Type.Number({ minimum: 0 }), { minItems: 1, maxItems: 100 }), observed: Type.Array(Type.Number({ minimum: 0 }), { minItems: 1, maxItems: 100 }) })),
15
+ }, { additionalProperties: false });
16
+ export type ReportInput = Static<typeof ReportSchema>;
17
+ export const InspectSchema = Type.Object({
18
+ action: Type.Union([Type.Literal('targets'), Type.Literal('issues'), Type.Literal('observations')]),
19
+ issueId: Type.Optional(text(64)),
20
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
21
+ }, { additionalProperties: false });
22
+ export type Target = { id: string; version: string };
23
+ export type Payload = { title: string; body: string };
24
+ export const OBSERVATION = 'signal-observation';
25
+ export type SignalModel = { provider: string; id: string; thinkingLevel?: import('@earendil-works/pi-coding-agent').ExtensionContext['thinkingLevel'] };
26
+ export type Capture = { id: string; observationId: string; component: string; version: string; category: ReportInput['category']; payload: Payload; model?: SignalModel };
27
+ export const TriageSchema = Type.Object({
28
+ verdict: Type.Union([Type.Literal('attention'), Type.Literal('flaky'), Type.Literal('needs_evidence')]),
29
+ reason: text(6000), evidence: Type.Array(text(4000), { minItems: 1, maxItems: 20 }),
30
+ }, { additionalProperties: false });
31
+ export type Triage = Static<typeof TriageSchema>;
package/src/store.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmodSync, mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import type { Capture, Triage } from './schema.ts';
6
+
7
+ export type Status = 'new' | 'triaging' | 'flaky' | 'needs_evidence' | 'attention' | 'repairing' | 'verified' | 'resolved' | 'blocked';
8
+ export type LocalIssue = { id: string; component: string; category: string; title: string; status: Status; revision: number; sightings: number; created: number; updated: number; decision?: Triage; model?: Capture['model']; lastError?: string };
9
+ export class SignalStore {
10
+ private readonly db: DatabaseSync;
11
+ constructor(path: string) {
12
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
13
+ this.db = new DatabaseSync(path); chmodSync(path, 0o600);
14
+ this.db.exec(`PRAGMA busy_timeout=3000; PRAGMA journal_mode=WAL;
15
+ CREATE TABLE IF NOT EXISTS issues (id TEXT PRIMARY KEY,component TEXT NOT NULL,category TEXT NOT NULL,title TEXT NOT NULL,status TEXT NOT NULL,
16
+ revision INTEGER NOT NULL,created INTEGER NOT NULL,updated INTEGER NOT NULL,model TEXT,decision TEXT,lastError TEXT);
17
+ CREATE TABLE IF NOT EXISTS observations (id TEXT PRIMARY KEY,issueId TEXT NOT NULL,version TEXT NOT NULL,payload TEXT NOT NULL,created INTEGER NOT NULL);
18
+ CREATE INDEX IF NOT EXISTS issue_observations ON observations(issueId,created);
19
+ CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY,issueId TEXT NOT NULL,revision INTEGER NOT NULL,phase TEXT NOT NULL,owner TEXT NOT NULL,
20
+ state TEXT NOT NULL,started INTEGER NOT NULL,finished INTEGER,result TEXT);
21
+ CREATE TABLE IF NOT EXISTS leases (name TEXT PRIMARY KEY,owner TEXT NOT NULL,expires INTEGER NOT NULL);
22
+ CREATE TABLE IF NOT EXISTS schedule (day TEXT PRIMARY KEY,started INTEGER NOT NULL,finished INTEGER)`);
23
+ }
24
+ close() { this.db.close(); }
25
+ private transaction<T>(work: () => T): T {
26
+ this.db.exec('BEGIN IMMEDIATE');
27
+ try { const result = work(); this.db.exec('COMMIT'); return result; }
28
+ catch (error) { this.db.exec('ROLLBACK'); throw error; }
29
+ }
30
+ get(id: string): LocalIssue | undefined {
31
+ const row = this.db.prepare('SELECT i.*,(SELECT COUNT(*) FROM observations o WHERE o.issueId=i.id) sightings FROM issues i WHERE id=?').get(id);
32
+ return row ? { ...row, decision: row.decision ? JSON.parse(String(row.decision)) : undefined, model: row.model ? JSON.parse(String(row.model)) : undefined } as unknown as LocalIssue : undefined;
33
+ }
34
+ list(offset = 0) { return this.db.prepare('SELECT id FROM issues ORDER BY updated DESC,id LIMIT 21 OFFSET ?').all(offset).map(row => this.get(String(row.id))!); }
35
+ observations(id: string, offset = 0, limit = 21) {
36
+ return this.db.prepare('SELECT * FROM observations WHERE issueId=? ORDER BY created,id LIMIT ? OFFSET ?').all(id, limit, offset)
37
+ .map(row => ({ ...row, payload: JSON.parse(String(row.payload)) }));
38
+ }
39
+ capture(value: Capture, now = Date.now()) {
40
+ return this.transaction(() => {
41
+ this.db.prepare("INSERT OR IGNORE INTO issues(id,component,category,title,status,revision,created,updated,model) VALUES(?,?,?,?,'new',0,?,?,?)")
42
+ .run(value.id, value.component, value.category, value.payload.title, now, now, value.model ? JSON.stringify(value.model) : null);
43
+ const added = this.db.prepare('INSERT OR IGNORE INTO observations(id,issueId,version,payload,created) VALUES(?,?,?,?,?)')
44
+ .run(value.observationId, value.id, value.version, JSON.stringify(value.payload), now).changes;
45
+ if (added) this.db.prepare("UPDATE issues SET revision=revision+1,updated=?,status=CASE WHEN status IN ('triaging','repairing') THEN status ELSE 'new' END WHERE id=?")
46
+ .run(now, value.id);
47
+ const issue = this.get(value.id)!;
48
+ return { id: issue.id, status: issue.status, sightings: issue.sightings, duplicate: !added };
49
+ });
50
+ }
51
+ pending() { return this.db.prepare("SELECT id FROM issues WHERE status IN ('new','attention') ORDER BY created,id").all().map(row => this.get(String(row.id))!); }
52
+ retry(id: string) { return !!this.db.prepare("UPDATE issues SET status='new',updated=? WHERE id=? AND status NOT IN ('triaging','repairing')").run(Date.now(), id).changes; }
53
+ acquire(name: string, durationMs: number, now = Date.now()): string | undefined {
54
+ const owner = randomUUID();
55
+ const changed = this.db.prepare('INSERT INTO leases(name,owner,expires) VALUES(?,?,?) ON CONFLICT(name) DO UPDATE SET owner=excluded.owner,expires=excluded.expires WHERE leases.expires<=?')
56
+ .run(name, owner, now + durationMs, now).changes;
57
+ return changed ? owner : undefined;
58
+ }
59
+ renew(name: string, owner: string, durationMs: number) {
60
+ return !!this.db.prepare('UPDATE leases SET expires=? WHERE name=? AND owner=? AND expires>?').run(Date.now() + durationMs, name, owner, Date.now()).changes;
61
+ }
62
+ release(name: string, owner: string) { this.db.prepare('DELETE FROM leases WHERE name=? AND owner=?').run(name, owner); }
63
+ private assertOwner(owner: string) {
64
+ if (!this.db.prepare("SELECT 1 FROM leases WHERE name='worker' AND owner=? AND expires>?").get(owner, Date.now())) throw new Error('Worker lease lost; stale decisions cannot be committed.');
65
+ }
66
+ due(day: string) { return !this.db.prepare('SELECT 1 FROM schedule WHERE day=? AND finished IS NOT NULL').get(day); }
67
+ startDay(day: string) { this.db.prepare('INSERT OR IGNORE INTO schedule(day,started) VALUES(?,?)').run(day, Date.now()); }
68
+ finishDay(day: string) { this.db.prepare('UPDATE schedule SET finished=? WHERE day=?').run(Date.now(), day); }
69
+ begin(issue: LocalIssue, owner: string, phase: 'triage' | 'repair') {
70
+ const id = randomUUID();
71
+ this.transaction(() => {
72
+ this.assertOwner(owner);
73
+ this.db.prepare('INSERT INTO runs(id,issueId,revision,phase,owner,state,started) VALUES(?,?,?,?,?,?,?)')
74
+ .run(id, issue.id, issue.revision, phase, owner, 'running', Date.now());
75
+ this.db.prepare('UPDATE issues SET status=?,lastError=NULL WHERE id=?').run(phase === 'triage' ? 'triaging' : 'repairing', issue.id);
76
+ });
77
+ return id;
78
+ }
79
+ finish(run: string, status: Status, result: unknown, decision?: Triage) {
80
+ this.transaction(() => {
81
+ const row = this.db.prepare('SELECT * FROM runs WHERE id=? AND state=?').get(run, 'running');
82
+ if (!row) throw new Error('The signals run is no longer active.');
83
+ this.assertOwner(String(row.owner));
84
+ this.db.prepare("UPDATE runs SET state='finished',finished=?,result=? WHERE id=?").run(Date.now(), JSON.stringify(result), run);
85
+ this.db.prepare("UPDATE issues SET status=CASE WHEN revision>? THEN 'new' ELSE ? END,decision=COALESCE(?,decision),updated=? WHERE id=?")
86
+ .run(Number(row.revision), status, decision ? JSON.stringify(decision) : null, Date.now(), String(row.issueId));
87
+ });
88
+ }
89
+ recover(owner: string) {
90
+ this.assertOwner(owner);
91
+ // An interrupted run never counts as fixed.
92
+ this.db.exec("UPDATE issues SET status='new',lastError='Previous worker interrupted; evidence retained' WHERE status IN ('triaging','repairing'); UPDATE runs SET state='interrupted' WHERE state='running'");
93
+ }
94
+ runs(id: string) { return this.db.prepare('SELECT * FROM runs WHERE issueId=? ORDER BY started').all(id).map(row => ({ ...row, id: String(row.id), state: String(row.state), result: row.result ? JSON.parse(String(row.result)) : undefined })); }
95
+ }