From c5eba63218fb9599262d5bdcf44798f11552966f Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 8 Oct 2026 02:45:15 +0000 Subject: [PATCH 01/11] feat(ticktick): complete completed-task listings past the per-call limit tasks completed walks the completion window page by page, deduplicates boundary ties, and reports pages and complete. A listing that stops at the page budget carries a warning and fails --require-complete. The positional day count now sets the window start. --- packages/adapter-ticktick/tasks.js | 38 +++++++++- .../test/completed-pages.test.js | 76 +++++++++++++++++++ packages/cli/bin/ats.js | 13 +++- packages/cli/parser.js | 6 +- packages/cli/test/ticktick-routing.test.js | 5 ++ 5 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 packages/adapter-ticktick/test/completed-pages.test.js diff --git a/packages/adapter-ticktick/tasks.js b/packages/adapter-ticktick/tasks.js index f57e001..7fe85d8 100644 --- a/packages/adapter-ticktick/tasks.js +++ b/packages/adapter-ticktick/tasks.js @@ -8,6 +8,9 @@ import * as usageLog from '@reneza/ats-core/usage-log'; import * as corpusCache from '@reneza/ats-core/corpus-cache'; import * as retrieval from '@reneza/ats-core/retrieval'; +const COMPLETED_PAGE = 200; +const COMPLETED_MAX_PAGES = 50; + // TickTick wants a full ISO datetime; normalize bare YYYY-MM-DD (e.g. ats --due 2026-06-20). function normalizeDue(d) { if (typeof d !== 'string' || d === '') return d; @@ -440,7 +443,32 @@ export async function listCompleted(options = {}, deps = {}) { if (options.startDate) body.startDate = options.startDate; if (options.endDate) body.endDate = options.endDate; - const tasks = await apiRequest('POST', '/task/completed', body, deps); + // The endpoint answers at most COMPLETED_PAGE tasks per call, newest first. A full + // page moves the window's end to the oldest completion it returned (inclusive, so + // ties are re-read and deduplicated) until a short page or the page budget. + const pageSize = options.pageSize || COMPLETED_PAGE; + const maxPages = options.maxPages || COMPLETED_MAX_PAGES; + const seen = new Map(); + let pages = 0; + let complete = false; + let windowEnd = body.endDate; + while (pages < maxPages) { + const page = await apiRequest('POST', '/task/completed', windowEnd ? { ...body, endDate: windowEnd } : body, deps); + pages += 1; + const list = Array.isArray(page) ? page : []; + const before = seen.size; + for (const t of list) if (t?.id && !seen.has(t.id)) seen.set(t.id, t); + if (list.length < pageSize) { complete = true; break; } + const oldest = list.reduce((min, t) => { + const ms = Date.parse(t?.completedTime); + return Number.isFinite(ms) && (min === null || ms < min.ms) ? { ms, raw: t.completedTime } : min; + }, null); + if (!oldest || seen.size === before) break; + if (body.startDate && oldest.ms <= Date.parse(body.startDate)) { complete = true; break; } + windowEnd = oldest.raw; + } + + const tasks = [...seen.values()]; const results = tasks.map((t) => ({ id: shortId(t.id), fullId: t.id, @@ -456,10 +484,16 @@ export async function listCompleted(options = {}, deps = {}) { results.sort((a, b) => new Date(b.completedTime) - new Date(a.completedTime)); - return { + const out = { count: results.length, tasks: results, + pages, + complete, }; + if (!complete) { + out.warnings = [`completed listing stopped after ${pages} page(s) of ${pageSize}; narrow --from/--to or --projects for the rest`]; + } + return out; } /** diff --git a/packages/adapter-ticktick/test/completed-pages.test.js b/packages/adapter-ticktick/test/completed-pages.test.js new file mode 100644 index 0000000..0c236bf --- /dev/null +++ b/packages/adapter-ticktick/test/completed-pages.test.js @@ -0,0 +1,76 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { listCompleted } from '../tasks.js'; + +const deps = { + shortId: (id) => id.slice(0, 8), + formatPriority: () => 'none', +}; + +// Newest-first store of `n` completions, one minute apart, ending at 2026-09-30T12:00Z. +function store(n) { + const end = Date.parse('2026-09-30T12:00:00.000Z'); + return Array.from({ length: n }, (_, i) => ({ + id: `task-${String(i).padStart(4, '0')}`, + projectId: 'project-1', + title: `done ${i}`, + completedTime: new Date(end - i * 60000).toISOString().replace('Z', '+0000'), + })); +} + +// Mimics the endpoint: completions inside [startDate, endDate], newest first, capped at `cap`. +function endpoint(all, calls, cap = 200) { + return async (method, path, body) => { + assert.equal(method, 'POST'); + assert.equal(path, '/task/completed'); + calls.push({ ...body }); + const from = body.startDate ? Date.parse(body.startDate) : -Infinity; + const to = body.endDate ? Date.parse(body.endDate) : Infinity; + return all.filter((t) => { + const ms = Date.parse(t.completedTime); + return ms >= from && ms <= to; + }).slice(0, cap); + }; +} + +test('listCompleted walks past the per-call cap and reports a complete listing', async () => { + const all = store(450); + const calls = []; + const result = await listCompleted({}, { ...deps, apiRequest: endpoint(all, calls) }); + assert.equal(result.count, 450); + assert.equal(new Set(result.tasks.map((t) => t.fullId)).size, 450); + assert.equal(result.complete, true); + assert.equal(result.pages, 3); + assert.equal(result.warnings, undefined); + assert.equal(calls[0].endDate, undefined); + assert.equal(calls[1].endDate, all[199].completedTime); +}); + +test('listCompleted keeps one call for a short page and preserves the caller window', async () => { + const calls = []; + const result = await listCompleted({ + projectIds: ['project-1'], startDate: '2026-09-30T11:00:00.000+0000', endDate: '2026-09-30T12:00:00.000+0000', + }, { ...deps, apiRequest: endpoint(store(450), calls) }); + assert.equal(result.count, 61); + assert.equal(result.complete, true); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].projectIds, ['project-1']); +}); + +test('listCompleted marks the result incomplete when the page budget runs out', async () => { + const calls = []; + const result = await listCompleted({ maxPages: 2 }, { ...deps, apiRequest: endpoint(store(900), calls) }); + assert.equal(calls.length, 2); + assert.equal(result.count, 399); + assert.equal(result.complete, false); + assert.match(result.warnings[0], /stopped after 2 page/); +}); + +test('listCompleted stops instead of looping when a full page shares one timestamp', async () => { + const same = store(300).map((t) => ({ ...t, completedTime: '2026-09-30T12:00:00.000+0000' })); + const calls = []; + const result = await listCompleted({}, { ...deps, apiRequest: endpoint(same, calls) }); + assert.equal(calls.length, 2); + assert.equal(result.count, 200); + assert.equal(result.complete, false); +}); diff --git a/packages/cli/bin/ats.js b/packages/cli/bin/ats.js index 6b61644..32ccac4 100755 --- a/packages/cli/bin/ats.js +++ b/packages/cli/bin/ats.js @@ -977,6 +977,11 @@ function needsTaskExt(method, sub) { ); } +/** TickTick's completed-listing window format: 2026-03-06T00:00:00.000+0000. */ +function tickTickTimestamp(date) { + return date.toISOString().replace('Z', '+0000'); +} + function tagsToArray(tags) { if (Array.isArray(tags)) return tags; if (typeof tags === 'string') return tags.split(',').map((s) => s.trim()).filter(Boolean); @@ -2075,10 +2080,16 @@ async function handleTasks() { return t?.priority ? await t.priority() : needsTaskExt('priority', 'priority'); case 'completed': { const projectIds = tagsToArray(args.options.projects); + let startDate = args.options.from; + if (!startDate && args.positional[0] !== undefined) { + const days = Number(args.positional[0]); + if (!Number.isInteger(days) || days < 1) throw new Error('tasks completed DAYS must be a positive integer.'); + startDate = tickTickTimestamp(new Date(Date.now() - days * 86400000)); + } return t?.listCompleted ? await t.listCompleted({ projectIds, folder: args.options.folder, - startDate: args.options.from, + startDate, endDate: args.options.to, }) : needsTaskExt('listCompleted', 'completed'); } diff --git a/packages/cli/parser.js b/packages/cli/parser.js index a939abe..109a167 100644 --- a/packages/cli/parser.js +++ b/packages/cli/parser.js @@ -1101,7 +1101,8 @@ Subcommands: similar Find semantically similar tasks due [days] Tasks due within N days (default: 7) priority High priority tasks - completed List completed tasks in a date range + completed [days] List completed tasks (last N days, or --from/--to); pages + past the 200-per-call limit and reports complete vector-sync [--all] Sync tasks into vector index (--all drains the whole backfill) vector-status Check vector index health @@ -1172,5 +1173,6 @@ Examples: ats tasks vector-sync ats tasks due 3 ats tasks completed --from 2026-03-06T00:00:00.000+0000 --to 2026-03-06T23:59:59.000+0000 - ats tasks completed --projects PROJECT_ID1,PROJECT_ID2`; + ats tasks completed --projects PROJECT_ID1,PROJECT_ID2 + ats tasks completed 30 --projects PROJECT_ID --require-complete`; } diff --git a/packages/cli/test/ticktick-routing.test.js b/packages/cli/test/ticktick-routing.test.js index 59e345b..d492977 100644 --- a/packages/cli/test/ticktick-routing.test.js +++ b/packages/cli/test/ticktick-routing.test.js @@ -169,6 +169,11 @@ test('routes TickTick task lifecycle and filter options', () => { op: 'tasks.completed', args: [{ projectIds: ['p1', 'p2'], folder: 'g1', startDate: 'a', endDate: 'b' }], }); + const windowed = run('tasks', 'completed', '30', '--projects', 'p1'); + const startMs = Date.parse(windowed.args[0].startDate.replace('+0000', 'Z')); + assert.match(windowed.args[0].startDate, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\+0000$/); + assert.ok(Math.abs(Date.now() - 30 * 86400000 - startMs) < 600000); + assert.deepEqual(windowed.args[0].projectIds, ['p1']); assert.deepEqual(run('tasks', 'semantic', 'query', '--limit', '2', '--priority', 'medium'), { op: 'tasks.semantic', args: ['query', { limit: 2, priority: 'medium' }], From d78fab3c3ef46f571979f1976bb406af2effa264 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 8 Oct 2026 02:48:29 +0000 Subject: [PATCH 02/11] feat(ledger): record the acting agent, human or session on every write Ledger records carry actor { id, kind, session? }. A global --agent names the acting agent for every write in one invocation; ATS_ACTOR_KIND and ATS_SESSION_ID are honored. ledger list filters by --actor-kind and --session, and history revisions show the actor. --- packages/cli/bin/ats.js | 11 +++++ packages/cli/parser.js | 10 ++++- packages/core/action-ledger.d.ts | 11 ++++- packages/core/action-ledger.js | 38 +++++++++++++++++- packages/core/index.js | 2 +- packages/core/test/action-actor.test.js | 53 +++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 packages/core/test/action-actor.test.js diff --git a/packages/cli/bin/ats.js b/packages/cli/bin/ats.js index 32ccac4..26d0851 100755 --- a/packages/cli/bin/ats.js +++ b/packages/cli/bin/ats.js @@ -70,6 +70,7 @@ import { evaluateTaskHierarchy, contextForTask, recordAction, + resolveActor, listActions, snapshotTask, taskHistory, @@ -251,6 +252,12 @@ async function main() { console.log(pkg.version); return; } + // `--agent NAME` names the acting agent for every write in this invocation; + // on `ledger list` it stays a filter. + const listingLedger = args.command === 'ledger' && args.subcommand === 'list'; + if (typeof args.options.agent === 'string' && args.options.agent.trim() && !listingLedger) { + process.env.ATS_AGENT_ID = args.options.agent.trim(); + } if (!args.command || (args.options.help && !args.command)) { console.log(getMainHelp()); return; @@ -1009,6 +1016,7 @@ function auditCliWrite(action, result, fallback, metadata, advanced = false, bef try { return recordAction({ agent: args.options.agent || process.env.ATS_AGENT_ID || 'ats-cli', + actor: resolveActor(), action, task, advanced, @@ -2431,6 +2439,7 @@ async function handleLedger() { } return recordAction({ agent: args.options.agent || process.env.ATS_AGENT_ID || 'ats-cli', + actor: resolveActor(), action: args.options.action, task: { projectId, taskId }, sources: tagsToArray(args.options.sources) || [], @@ -2444,6 +2453,8 @@ async function handleLedger() { projectId: args.options.project, taskId: args.options.task, agent: args.options.agent, + actorKind: args.options['actor-kind'], + session: args.options.session, action: args.options.action, advanced: booleanOption(args.options.advanced, 'advanced'), limit: parseInt(args.options.limit) || undefined, diff --git a/packages/cli/parser.js b/packages/cli/parser.js index 109a167..41a6d80 100644 --- a/packages/cli/parser.js +++ b/packages/cli/parser.js @@ -30,7 +30,7 @@ const VALUE_OPTIONS = new Set([ 'format', 'content', 'append', 'prepend', 'title', 'project', 'projects', 'limit', 'budget-ms', 'timeout-ms', 'input', 'output', 'file', 'out', 'journal', 'if-match', 'idempotency-key', 'due', 'priority', 'tags', 'reminder', - 'domain', 'source', 'subject', 'predicate', 'confidence', 'task', 'by', 'agent', + 'domain', 'source', 'subject', 'predicate', 'confidence', 'task', 'by', 'agent', 'actor-kind', 'session', 'reason', 'url', 'type', 'desc', 'display', 'extract', 'folder', 'from', 'to', 'days', 'since', 'state', 'spool', 'interval', 'due-within-hours', 'max', 'threshold', 'max-corpus', 'rerank-depth', 'min-sources', 'facts-limit', @@ -494,6 +494,7 @@ Global options: --version, -v Show version --format Output format: text (default) or json --json Shorthand for --format json (machine-readable, pipe to jq) + --agent Acting agent recorded on every write (default ATS_AGENT_ID) --require-complete Exit 2 for stale, degraded or explicitly incomplete reads -- Treat remaining arguments as literal positionals @@ -667,7 +668,12 @@ Record options: --output Concise result or artifact reference --advanced Whether the action advanced the task -List filters: --project, --task, --agent, --action, --advanced, --limit`, +List filters: --project, --task, --agent, --actor-kind agent|human|unattributed, + --session, --action, --advanced, --limit + +Every record carries actor: { id, kind, session? }. kind is agent when --agent or +ATS_AGENT_ID names one, human for ATS_ACTOR_KIND=human or an interactive terminal, +otherwise unattributed. ATS_SESSION_ID binds the session.`, security: `ats security - Portable task access policy and audited decisions Usage: diff --git a/packages/core/action-ledger.d.ts b/packages/core/action-ledger.d.ts index efcf767..52376e5 100644 --- a/packages/core/action-ledger.d.ts +++ b/packages/core/action-ledger.d.ts @@ -1,7 +1,14 @@ +export interface ActionActor { + id: string; + kind: 'agent' | 'human' | 'unattributed'; + session?: string; +} + export interface ActionLedgerEntry { id?: string; ts?: string; agent?: string; + actor?: ActionActor; action: string; task?: { projectId: string; taskId: string } | null; sources?: string[]; @@ -20,6 +27,7 @@ export interface ActionLedgerRecord extends ActionLedgerEntry { id: string; ts: string; agent: string; + actor: ActionActor; sources: string[]; approvals: string[]; advanced: boolean; @@ -40,8 +48,9 @@ export interface RevertResult { } export function actionLogPath(): string; +export function resolveActor(options?: { agent?: string; kind?: ActionActor['kind']; session?: string; env?: Record; interactive?: boolean }): ActionActor; export function recordAction(entry: ActionLedgerEntry, options?: { logPath?: string }): ActionLedgerRecord | null; -export function listActions(filters?: { agent?: string; action?: string; projectId?: string; taskId?: string; advanced?: boolean; limit?: number }, options?: { logPath?: string }): ActionLedgerRecord[]; +export function listActions(filters?: { agent?: string; actorKind?: ActionActor['kind']; session?: string; action?: string; projectId?: string; taskId?: string; advanced?: boolean; limit?: number }, options?: { logPath?: string }): ActionLedgerRecord[]; export function snapshotTask(task?: unknown): TaskSnapshot; export function taskHistory(projectId: string, taskId: string, options?: { limit?: number; logPath?: string }): { task: { projectId: string; taskId: string }; diff --git a/packages/core/action-ledger.js b/packages/core/action-ledger.js index 16bb5a4..b2ee99a 100644 --- a/packages/core/action-ledger.js +++ b/packages/core/action-ledger.js @@ -9,6 +9,38 @@ export function actionLogPath() { return process.env.ATS_ACTION_LOG || path.join(configBase, 'ats', 'action-log.jsonl'); } +const ACTOR_KINDS = new Set(['agent', 'human', 'unattributed']); + +/** + * Who performs a write: `{ id, kind, session? }`. + * kind is `agent` when an agent identity is supplied (argument or ATS_AGENT_ID), + * `human` when ATS_ACTOR_KIND=human or the process runs on an interactive terminal + * without one, and `unattributed` otherwise. ATS_SESSION_ID binds the session. + */ +export function resolveActor({ agent, kind, session, env = process.env, interactive } = {}) { + const agentId = typeof agent === 'string' && agent.trim() ? agent.trim() + : (env.ATS_AGENT_ID && env.ATS_AGENT_ID.trim()) || null; + const tty = interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY); + let resolvedKind = kind || (env.ATS_ACTOR_KIND && ACTOR_KINDS.has(env.ATS_ACTOR_KIND) ? env.ATS_ACTOR_KIND : null); + if (!resolvedKind) resolvedKind = agentId ? 'agent' : tty ? 'human' : 'unattributed'; + if (!ACTOR_KINDS.has(resolvedKind)) throw new Error(`Unknown actor kind: ${resolvedKind}`); + const id = agentId || (resolvedKind === 'human' ? env.ATS_REVIEWER || env.USER || 'human' : 'unknown-agent'); + const actor = { id, kind: resolvedKind }; + const sessionId = session || env.ATS_SESSION_ID; + if (sessionId) actor.session = String(sessionId); + return actor; +} + +function normalizeActor(actor) { + if (actor === undefined || actor === null) return null; + if (typeof actor !== 'object' || typeof actor.id !== 'string' || !actor.id || !ACTOR_KINDS.has(actor.kind)) { + throw new Error('Action ledger actor requires id and kind (agent, human or unattributed).'); + } + const out = { id: actor.id, kind: actor.kind }; + if (actor.session) out.session = String(actor.session); + return out; +} + function buildRecord(entry) { if (!entry || typeof entry !== 'object') throw new Error('Action ledger entry must be an object.'); if (!entry.action || typeof entry.action !== 'string') throw new Error('Action ledger entry requires an action.'); @@ -37,6 +69,7 @@ function buildRecord(entry) { id: entry.id || randomUUID(), ts: entry.ts || new Date().toISOString(), agent: entry.agent || process.env.ATS_AGENT_ID || 'unknown-agent', + actor: normalizeActor(entry.actor) || resolveActor(), action: entry.action, task: entry.task || null, sources: Array.isArray(entry.sources) ? entry.sources : [], @@ -89,7 +122,9 @@ export function listActions(filters = {}, { logPath = actionLogPath() } = {}) { throw new Error(`Malformed action ledger JSON at line ${index + 1}.`, { cause: err }); } }) - .filter((entry) => !filters.agent || entry.agent === filters.agent) + .filter((entry) => !filters.agent || entry.agent === filters.agent || entry.actor?.id === filters.agent) + .filter((entry) => !filters.actorKind || (entry.actor?.kind || 'unattributed') === filters.actorKind) + .filter((entry) => !filters.session || entry.actor?.session === filters.session) .filter((entry) => !filters.action || entry.action === filters.action) .filter((entry) => !filters.projectId || entry.task?.projectId === filters.projectId) .filter((entry) => !filters.taskId || entry.task?.taskId === filters.taskId) @@ -131,6 +166,7 @@ export function taskHistory(projectId, taskId, { limit, logPath = actionLogPath( ts: entry.ts, action: entry.action, agent: entry.agent, + ...(entry.actor ? { actor: entry.actor } : {}), restorable: Boolean(entry.before && Object.keys(entry.before).length), before: entry.before, after: entry.after, diff --git a/packages/core/index.js b/packages/core/index.js index 5198f7e..bb60821 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -84,7 +84,7 @@ export { evaluateTaskHierarchy, contextForTask, } from './task-context.js'; -export { actionLogPath, recordAction, listActions, snapshotTask, taskHistory, findAction, mostRecentUndoable, revertAction } from './action-ledger.js'; +export { actionLogPath, resolveActor, recordAction, listActions, snapshotTask, taskHistory, findAction, mostRecentUndoable, revertAction } from './action-ledger.js'; export { TASK_EVENT_STATE_VERSION, TASK_EVENT_SPOOL_VERSION, diff --git a/packages/core/test/action-actor.test.js b/packages/core/test/action-actor.test.js new file mode 100644 index 0000000..744c5a0 --- /dev/null +++ b/packages/core/test/action-actor.test.js @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { listActions, recordAction, resolveActor, taskHistory } from '../action-ledger.js'; + +test('resolveActor names agents, humans and unattributed callers', () => { + assert.deepEqual(resolveActor({ agent: 'planner', env: {}, interactive: false }), { id: 'planner', kind: 'agent' }); + assert.deepEqual(resolveActor({ env: { ATS_AGENT_ID: 'coder', ATS_SESSION_ID: 's-1' }, interactive: true }), + { id: 'coder', kind: 'agent', session: 's-1' }); + assert.deepEqual(resolveActor({ env: { USER: 'pat' }, interactive: true }), { id: 'pat', kind: 'human' }); + assert.deepEqual(resolveActor({ env: { ATS_ACTOR_KIND: 'human', ATS_REVIEWER: 'lead' }, interactive: false }), + { id: 'lead', kind: 'human' }); + assert.deepEqual(resolveActor({ env: {}, interactive: false }), { id: 'unknown-agent', kind: 'unattributed' }); + assert.throws(() => resolveActor({ kind: 'robot', env: {} }), /Unknown actor kind/); +}); + +test('ledger records carry the actor and filter by kind and session', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ats-actor-')); + const logPath = path.join(dir, 'action-log.jsonl'); + try { + const task = { projectId: 'p1', taskId: 't1' }; + recordAction({ agent: 'coder', actor: { id: 'coder', kind: 'agent', session: 's-1' }, action: 'task.updated', task }, { logPath }); + recordAction({ agent: 'ats-cli', actor: { id: 'pat', kind: 'human' }, action: 'task.deleted', task }, { logPath }); + recordAction({ agent: 'other', actor: { id: 'other', kind: 'agent', session: 's-2' }, action: 'task.updated', task }, { logPath }); + + assert.equal(listActions({ actorKind: 'agent' }, { logPath }).length, 2); + assert.deepEqual(listActions({ actorKind: 'human' }, { logPath }).map((e) => e.action), ['task.deleted']); + assert.deepEqual(listActions({ session: 's-1' }, { logPath }).map((e) => e.actor.id), ['coder']); + assert.deepEqual(listActions({ agent: 'pat' }, { logPath }).map((e) => e.action), ['task.deleted']); + assert.equal(taskHistory('p1', 't1', { logPath }).revisions.every((r) => r.actor?.kind), true); + assert.throws(() => recordAction({ action: 'x', actor: { id: 'a', kind: 'robot' } }, { logPath }), /actor requires/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('records without an explicit actor resolve one from the environment', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ats-actor-env-')); + const logPath = path.join(dir, 'action-log.jsonl'); + const saved = { id: process.env.ATS_AGENT_ID, kind: process.env.ATS_ACTOR_KIND }; + try { + process.env.ATS_AGENT_ID = 'env-agent'; + delete process.env.ATS_ACTOR_KIND; + const rec = recordAction({ action: 'note' }, { logPath }); + assert.deepEqual(rec.actor, { id: 'env-agent', kind: 'agent' }); + } finally { + if (saved.id === undefined) delete process.env.ATS_AGENT_ID; else process.env.ATS_AGENT_ID = saved.id; + if (saved.kind !== undefined) process.env.ATS_ACTOR_KIND = saved.kind; + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 17956c9b9b4c4f8cd2fc1ad423ecff21605f7e55 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 8 Oct 2026 02:51:32 +0000 Subject: [PATCH 03/11] feat(kg): verify fact sources against the systems that hold them ats kg verify rechecks every active fact's source: task references through the active adapter, file paths by existence and modification after the fact was learned, and URLs with --network. Results are verified, changed, stale or unverifiable; any stale or changed source exits 2, and --propose-retract stages a reviewed retraction for each stale fact. --- packages/cli/bin/ats.js | 44 ++++++++++ packages/cli/parser.js | 6 +- packages/cli/test/kg-governance.test.js | 97 ++++++++++++++++++++++ packages/core/index.js | 3 + packages/core/kg-verify.js | 106 ++++++++++++++++++++++++ packages/core/kg.js | 1 + packages/core/test/kg-verify.test.js | 75 +++++++++++++++++ 7 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/kg-governance.test.js create mode 100644 packages/core/kg-verify.js create mode 100644 packages/core/test/kg-verify.test.js diff --git a/packages/cli/bin/ats.js b/packages/cli/bin/ats.js index 26d0851..6ea2898 100755 --- a/packages/cli/bin/ats.js +++ b/packages/cli/bin/ats.js @@ -107,6 +107,8 @@ import { proposeRetract, proposeConfirm, staleFacts, + verifyFacts, + classifySource, ratifyFactItem, listKgFacts, askFacts, @@ -1227,6 +1229,48 @@ async function handleKg() { } case 'stale': return staleFacts({ domain: args.options.domain, days: args.options.days === undefined ? 60 : Number(args.options.days), limit: args.options.limit === undefined ? 50 : Number(args.options.limit) }); + case 'verify': { + // Recheck each active fact's source against the system that holds it. + // Exit 2 when any source is stale or changed. + const needsAdapter = listKgFacts({ domain: args.options.domain }).some((fact) => classifySource(fact).kind === 'task'); + let getTask; + if (needsAdapter) { + try { + const adapter = await loadAdapter(); + getTask = (projectId, taskId) => adapter.getTask(projectId, taskId); + } catch (err) { + console.error(`Warning: task sources unverifiable — adapter unavailable: ${err.message}`); + } + } + const timeoutMs = args.options['timeout-ms'] === undefined ? 8000 : Number(args.options['timeout-ms']); + const fetchUrl = args.options.network ? async (url) => { + const res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: globalThis.AbortSignal.timeout(timeoutMs) }); + return res.status; + } : undefined; + const report = await verifyFacts({ + ids: args.positional, + domain: args.options.domain, + limit: args.options.limit === undefined ? undefined : Number(args.options.limit), + getTask, + fetchUrl, + }); + if (args.options['propose-retract']) { + report.proposed = []; + for (const fact of report.facts.filter((f) => f.status === 'stale')) { + try { + const item = proposeRetract({ factId: fact.id, reason: `source unavailable: ${fact.source} (${fact.reason})`, by: agentId }); + report.proposed.push({ factId: fact.id, reviewId: item.id }); + } catch (err) { + report.proposed.push({ factId: fact.id, error: err.message }); + } + } + } + if (!report.ok) { + console.log(formatOutput(report, args.options.format)); + process.exit(2); + } + return report; + } case 'confirm': { const item = proposeConfirm({ factId: args.positional[0], source: args.options.source, by: agentId }); return { staged: true, reviewId: item.id, message: 'Confirmation staged; approve it and run ats kg ratify.' }; diff --git a/packages/cli/parser.js b/packages/cli/parser.js index 41a6d80..50d3555 100644 --- a/packages/cli/parser.js +++ b/packages/cli/parser.js @@ -24,7 +24,7 @@ const BOOLEAN_OPTIONS = new Set([ 'force', 'write', 'dry-run', 'once', 'close', 'relevance', 'no-relevance', 'no-facts', 'semantic', 'lexical', 'include-retracted', 'cypher', 'graphiti', 'additive', 'clear-parent', 'allow-missing', 'live', 'require-complete', - 'if-absent', 'non-interactive', 'claim', 'native', 'n', + 'if-absent', 'non-interactive', 'claim', 'native', 'n', 'network', 'propose-retract', ]); const VALUE_OPTIONS = new Set([ 'format', 'content', 'append', 'prepend', 'title', 'project', 'projects', @@ -804,6 +804,10 @@ Usage: --confidence fill what a line lacks ats kg stale [--days N --domain D] Active facts due for evidence review ats kg confirm FACT_ID --source REF Reviewed evidence confirmation + ats kg verify [FACT_ID...] [--domain D] Recheck each fact's source: task://P/T and + --task refs through the adapter, file: paths, + URLs with --network; exit 2 when stale or + changed; --propose-retract stages retractions ats kg retract FACT_ID [--reason "..."] Retraction proposal — reviewed too ats kg pending [--domain D] What each queued proposal would do, checked against the store now diff --git a/packages/cli/test/kg-governance.test.js b/packages/cli/test/kg-governance.test.js new file mode 100644 index 0000000..02ba824 --- /dev/null +++ b/packages/cli/test/kg-governance.test.js @@ -0,0 +1,97 @@ +/** + * `ats kg verify` and review governance through the binary. + */ +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const cli = fileURLToPath(new URL('../bin/ats.js', import.meta.url)); +let tempDir; +let adapterUrl; + +before(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ats-kg-gov-')); + const adapterPath = path.join(tempDir, 'adapter.mjs'); + fs.writeFileSync(adapterPath, ` +const tasks = [{ id: 't1', title: 'Quarterly plan', content: '', projectId: 'p1', tags: [], modifiedTime: '2026-09-01T00:00:00.000Z' }]; +export default { + listProjects: async () => [{ id: 'p1', name: 'Ops' }], + listTasksInProject: async () => tasks, + getTask: async (_p, id) => tasks.find((t) => t.id === id), + createTask: async (input) => ({ id: 'new', projectId: 'p1', tags: [], modifiedTime: 'x', content: '', ...input }), + updateTask: async (projectId, id, patch) => ({ id, projectId, tags: [], modifiedTime: 'x', content: '', ...patch }), + urlFor: ({ taskId }) => 'test://' + taskId, + authStatus: async () => ({ authenticated: true }), + authLogin: async () => ({ instructions: 'none' }), +}; +`); + adapterUrl = pathToFileURL(adapterPath).href; +}); + +after(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +function runProcess(argv, { env = {}, store = 'main' } = {}) { + const dir = path.join(tempDir, store); + return spawnSync(process.execPath, [cli, ...argv, '--json'], { + encoding: 'utf8', + env: { + ...process.env, + ATS_ADAPTER: adapterUrl, + ATS_AGENT_ID: 'agent-test', + ATS_REVIEWER: 'reviewer', + ATS_KG_FACTS: path.join(dir, 'kg-facts.jsonl'), + ATS_REVIEW_QUEUE: path.join(dir, 'review-queue.json'), + ATS_ACTION_LOG: path.join(dir, 'action-log.jsonl'), + ATS_CORPUS_CACHE_DISABLE: '1', + ATS_USAGE_DISABLE: '1', + XDG_CONFIG_HOME: path.join(dir, 'xdg'), + ...env, + }, + }); +} + +function run(argv, opts) { + const proc = runProcess(argv, opts); + assert.equal(proc.status, 0, `${argv.join(' ')}\n${proc.stderr}\n${proc.stdout}`); + return JSON.parse(proc.stdout); +} + +function approveAndRatify(reviewId, opts = {}) { + run(['review', 'approve', reviewId], { ...opts, env: { ATS_AGENT_ID: '', ...(opts.env || {}) } }); + const out = run(['kg', 'ratify', reviewId], opts); + assert.equal(out.ratified[0].ok, true, JSON.stringify(out)); + return out.ratified[0].factId; +} + +test('kg verify rechecks task and file sources, exits 2 on a stale one and can stage its retraction', () => { + const opts = { store: 'verify' }; + const note = path.join(tempDir, 'source-note.md'); + fs.writeFileSync(note, 'notes'); + const old = new Date('2026-01-01T00:00:00Z'); + fs.utimesSync(note, old, old); + const live = approveAndRatify(run(['kg', 'propose', 'Quarterly plan', 'owned by', 'ops team', '--source', 'task://p1/t1'], opts).reviewId, opts); + const file = approveAndRatify(run(['kg', 'propose', 'Ops team', 'meets', 'weekly', '--source', `file:${note}`], opts).reviewId, opts); + + const healthy = run(['kg', 'verify'], opts); + assert.equal(healthy.ok, true); + assert.equal(healthy.verified, 2); + + const gone = approveAndRatify(run(['kg', 'propose', 'Old plan', 'owned by', 'ops team', '--source', 'task://p1/t9'], opts).reviewId, opts); + const proc = runProcess(['kg', 'verify', '--propose-retract'], opts); + assert.equal(proc.status, 2, proc.stderr); + const report = JSON.parse(proc.stdout); + assert.equal(report.stale, 1); + assert.equal(report.facts.find((f) => f.id === gone).status, 'stale'); + assert.equal(report.facts.find((f) => f.id === live).status, 'verified'); + assert.equal(report.facts.find((f) => f.id === file).status, 'verified'); + assert.equal(report.proposed.length, 1); + assert.equal(report.proposed[0].factId, gone); + const pending = run(['kg', 'pending'], opts); + assert.match(JSON.stringify(pending), new RegExp(`retract ${gone.slice(0, 8)}`)); +}); diff --git a/packages/core/index.js b/packages/core/index.js index bb60821..e698b40 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -41,6 +41,9 @@ export { askFactsSemantic, kgVectorsPath, listEntities, + verifyFacts, + verifyFactSource, + classifySource, } from './kg.js'; export { rrf, fuse, find, loadCorpus, similar, syncCorpusCache, projectScope, findConfidence, RRF_K } from './retrieval.js'; export { detectDuplicates, formatDedup } from './dedup.js'; diff --git a/packages/core/kg-verify.js b/packages/core/kg-verify.js new file mode 100644 index 0000000..dd9c649 --- /dev/null +++ b/packages/core/kg-verify.js @@ -0,0 +1,106 @@ +/** + * Source verification for the fact store: recheck each active fact's source + * against the system that holds it. + * + * Statuses: `verified` (the source still answers), `changed` (a file source was + * modified after the fact was learned), `stale` (the source is gone), and + * `unverifiable` (no checkable source, a check that was not requested, or a + * transport error that says nothing about the record). + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { listKgFacts } from './kg-store.js'; + +const NOT_FOUND = /not\s*found|no such|does not exist|404|410|deleted/i; + +/** Classify a fact's source reference. */ +export function classifySource(fact = {}) { + const source = typeof fact.provenance?.source === 'string' ? fact.provenance.source.trim() : ''; + if (fact.taskRef?.projectId && fact.taskRef?.taskId) { + return { kind: 'task', projectId: fact.taskRef.projectId, taskId: fact.taskRef.taskId, ref: `task://${fact.taskRef.projectId}/${fact.taskRef.taskId}` }; + } + if (!source) return { kind: 'none', ref: null }; + const task = /^task:\/\/([^/\s]+)\/([^/\s]+)$/.exec(source); + if (task) return { kind: 'task', projectId: task[1], taskId: task[2], ref: source }; + if (/^https?:\/\//i.test(source)) return { kind: 'url', url: source, ref: source }; + const file = /^file:(?:\/\/)?(.+)$/.exec(source); + if (file) return { kind: 'file', file: file[1], ref: source }; + if (source.startsWith('/') || source.startsWith('./') || source.startsWith('../')) return { kind: 'file', file: source, ref: source }; + return { kind: 'opaque', ref: source }; +} + +async function checkTask(src, getTask) { + if (typeof getTask !== 'function') return { status: 'unverifiable', reason: 'no adapter to read task sources' }; + try { + const result = await getTask(src.projectId, src.taskId); + const task = result?.task || result; + if (!task || task.deleted === true || task.deleted === 1) return { status: 'stale', reason: 'task not found' }; + return { status: 'verified', reason: task.status === 'completed' ? 'task exists (completed)' : 'task exists' }; + } catch (err) { + const message = String(err?.message || err); + return NOT_FOUND.test(message) + ? { status: 'stale', reason: `task not found: ${message}` } + : { status: 'unverifiable', reason: `task read failed: ${message}` }; + } +} + +function checkFile(src, fact, cwd) { + const abs = path.resolve(cwd, src.file); + let stat; + try { stat = fs.statSync(abs); } catch (err) { + return err.code === 'ENOENT' ? { status: 'stale', reason: 'file not found' } : { status: 'unverifiable', reason: err.message }; + } + const learned = Date.parse(fact.lastConfirmedAt || fact.tLearned || fact.provenance?.ratifiedAt || ''); + if (Number.isFinite(learned) && stat.mtimeMs > learned) { + return { status: 'changed', reason: `file modified ${new Date(stat.mtimeMs).toISOString()} after the fact was learned` }; + } + return { status: 'verified', reason: 'file exists, unchanged since the fact was learned' }; +} + +async function checkUrl(src, fetchUrl) { + if (typeof fetchUrl !== 'function') return { status: 'unverifiable', reason: 'network check not requested (--network)' }; + try { + const status = await fetchUrl(src.url); + if (status >= 200 && status < 400) return { status: 'verified', reason: `HTTP ${status}` }; + if (status === 404 || status === 410) return { status: 'stale', reason: `HTTP ${status}` }; + return { status: 'unverifiable', reason: `HTTP ${status}` }; + } catch (err) { + return { status: 'unverifiable', reason: `request failed: ${err?.message || err}` }; + } +} + +/** Verify one fact's source. */ +export async function verifyFactSource(fact, { getTask, fetchUrl, cwd = process.cwd() } = {}) { + const src = classifySource(fact); + let outcome; + if (src.kind === 'task') outcome = await checkTask(src, getTask); + else if (src.kind === 'file') outcome = checkFile(src, fact, cwd); + else if (src.kind === 'url') outcome = await checkUrl(src, fetchUrl); + else if (src.kind === 'none') outcome = { status: 'unverifiable', reason: 'fact has no source' }; + else outcome = { status: 'unverifiable', reason: 'source is not a task, file or URL reference' }; + return { id: fact.id, subject: fact.subject, predicate: fact.predicate, object: fact.object, domain: fact.domain, source: src.ref, sourceKind: src.kind, ...outcome }; +} + +/** + * Verify active facts (all, a domain, or named ids). Returns per-status counts, + * `ok` (no stale or changed source) and the per-fact results. + */ +export async function verifyFacts({ ids, domain, limit, factsPath, ...deps } = {}) { + let facts = listKgFacts({ domain, ...(factsPath ? { factsPath } : {}) }); + if (Array.isArray(ids) && ids.length) { + facts = ids.map((id) => { + const matches = facts.filter((fact) => fact.id === id || fact.id.startsWith(id)); + if (matches.length !== 1) throw new Error(matches.length ? `kg: fact id ${id} is ambiguous.` : `kg: no active fact ${id}.`); + return matches[0]; + }); + } + if (limit !== undefined) { + if (!Number.isInteger(limit) || limit < 1) throw new Error('kg: limit must be a positive integer.'); + facts = facts.slice(0, limit); + } + const results = []; + for (const fact of facts) results.push(await verifyFactSource(fact, deps)); + const counts = { verified: 0, changed: 0, stale: 0, unverifiable: 0 }; + for (const r of results) counts[r.status] += 1; + return { checked: results.length, ...counts, ok: counts.stale === 0 && counts.changed === 0, facts: results }; +} diff --git a/packages/core/kg.js b/packages/core/kg.js index f8adf0f..4855962 100644 --- a/packages/core/kg.js +++ b/packages/core/kg.js @@ -1,2 +1,3 @@ // Public entrypoint for the fact-store helpers. export * from './kg-store.js'; +export * from './kg-verify.js'; diff --git a/packages/core/test/kg-verify.test.js b/packages/core/test/kg-verify.test.js new file mode 100644 index 0000000..76fe4f8 --- /dev/null +++ b/packages/core/test/kg-verify.test.js @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { classifySource, verifyFacts } from '../kg-verify.js'; + +function writeFacts(dir, facts) { + const factsPath = path.join(dir, 'kg-facts.jsonl'); + fs.writeFileSync(factsPath, facts.map((fact) => JSON.stringify({ op: 'add', at: fact.tLearned, fact })).join('\n') + '\n'); + return factsPath; +} + +function fact(id, source, extra = {}) { + return { + id, subject: `s-${id}`, predicate: 'uses', object: 'thing', domain: 'work', + tValid: '2026-09-01T00:00:00.000Z', tLearned: '2026-09-01T00:00:00.000Z', confidence: 'medium', + provenance: { source, proposedBy: 'agent', ratifiedBy: 'reviewer', ratifiedAt: '2026-09-01T00:00:00.000Z' }, + ...extra, + }; +} + +test('classifySource recognizes task, file, URL and opaque references', () => { + assert.equal(classifySource(fact('a', 'task://p1/t1')).kind, 'task'); + assert.equal(classifySource(fact('a', null, { taskRef: { projectId: 'p', taskId: 't' } })).ref, 'task://p/t'); + assert.equal(classifySource(fact('a', 'file:/tmp/x.md')).file, '/tmp/x.md'); + assert.equal(classifySource(fact('a', './notes/x.md')).kind, 'file'); + assert.equal(classifySource(fact('a', 'https://example.com/a')).kind, 'url'); + assert.equal(classifySource(fact('a', 'meeting with the team')).kind, 'opaque'); + assert.equal(classifySource(fact('a', null)).kind, 'none'); +}); + +test('verifyFacts rechecks sources and reports stale, changed and unverifiable ones', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ats-kg-verify-')); + try { + const kept = path.join(dir, 'kept.md'); + const edited = path.join(dir, 'edited.md'); + fs.writeFileSync(kept, 'x'); + fs.writeFileSync(edited, 'y'); + const old = new Date('2026-08-01T00:00:00Z'); + fs.utimesSync(kept, old, old); + const factsPath = writeFacts(dir, [ + fact('f-task-ok', 'task://p1/t1'), + fact('f-task-gone', 'task://p1/t2'), + fact('f-task-auth', 'task://p1/t3'), + fact('f-file-ok', `file:${kept}`), + fact('f-file-edited', `file:${edited}`), + fact('f-file-gone', `file:${path.join(dir, 'missing.md')}`), + fact('f-url', 'https://example.com/page'), + fact('f-none', null), + ]); + const getTask = async (projectId, taskId) => { + if (taskId === 't1') return { task: { id: taskId, status: 'completed' } }; + if (taskId === 't2') throw new Error('Task not found'); + throw new Error('401 unauthorized'); + }; + const report = await verifyFacts({ factsPath, getTask }); + const by = Object.fromEntries(report.facts.map((r) => [r.id, r.status])); + assert.deepEqual(by, { + 'f-task-ok': 'verified', 'f-task-gone': 'stale', 'f-task-auth': 'unverifiable', + 'f-file-ok': 'verified', 'f-file-edited': 'changed', 'f-file-gone': 'stale', + 'f-url': 'unverifiable', 'f-none': 'unverifiable', + }); + assert.equal(report.ok, false); + assert.deepEqual([report.verified, report.changed, report.stale, report.unverifiable], [2, 1, 2, 3]); + + const online = await verifyFacts({ factsPath, ids: ['f-url'], fetchUrl: async () => 404 }); + assert.equal(online.facts[0].status, 'stale'); + const healthy = await verifyFacts({ factsPath, ids: ['f-task-ok', 'f-file-ok'], getTask }); + assert.equal(healthy.ok, true); + await assert.rejects(verifyFacts({ factsPath, ids: ['nope'] }), /no active fact/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From dfa1dd4aaeb86918ff5ae5349d398e53b831e47e Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 8 Oct 2026 02:54:42 +0000 Subject: [PATCH 04/11] fix(format): never drop text when conforming a task body The Goal+Log normalizer checks that every word of the input survives in its output. A body it cannot restructure without loss is written verbatim and the result reports skipped: content-loss with the affected words. Writes warn when a body arrives as one line of literal \n sequences. --- packages/cli/bin/ats.js | 20 +++++++++++++++++-- packages/cli/test/update-modes.test.js | 21 ++++++++++++++++++++ packages/core/index.js | 2 +- packages/core/task-format.js | 27 ++++++++++++++++++++++++++ packages/core/test/task-format.test.js | 24 ++++++++++++++++++++++- 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/cli/bin/ats.js b/packages/cli/bin/ats.js index 6ea2898..356ca56 100755 --- a/packages/cli/bin/ats.js +++ b/packages/cli/bin/ats.js @@ -85,6 +85,7 @@ import { snapshotTaskEvents, collectAndSpoolTaskEvents, normalizeTaskBody, + hasLiteralNewlineEscapes, contentHash, TRIAGE_TAG, syncCorpusCache, @@ -986,6 +987,21 @@ function needsTaskExt(method, sub) { ); } +/** + * Normalize a body that is about to be written. A body the normalizer cannot + * restructure without dropping text is written verbatim, with a warning. + */ +function conformBody(text) { + if (hasLiteralNewlineEscapes(text)) { + console.error('Warning: the body is one line with literal \\n sequences; pass real line breaks (--input FILE, stdin, or $\'...\' quoting).'); + } + const norm = normalizeTaskBody(text); + if (norm.skipped === 'content-loss') { + console.error(`Warning: body kept verbatim; normalizing would drop: ${norm.lost.join(', ')}`); + } + return norm.content; +} + /** TickTick's completed-listing window format: 2026-03-06T00:00:00.000+0000. */ function tickTickTimestamp(date) { return date.toISOString().replace('Z', '+0000'); @@ -1847,7 +1863,7 @@ async function handleTasks() { // passed by NAME is not recognized by the skip). --raw is the per-call form: // a body rendered deterministically by a tool must survive byte-for-byte. if (opts.content && !formatSkipped(projectId) && args.options.raw !== true) { - opts.content = normalizeTaskBody(opts.content).content; + opts.content = conformBody(opts.content); } if (args.options['dry-run'] === true) { return { @@ -1998,7 +2014,7 @@ async function handleTasks() { // --raw is the per-call form of format-skip: a body rendered // deterministically by a tool must survive the write byte-for-byte. if (patch.content !== undefined && !formatSkipped(up) && args.options.raw !== true) { - patch.content = normalizeTaskBody(patch.content).content; + patch.content = conformBody(patch.content); } if (args.options['dry-run'] === true) { return { diff --git a/packages/cli/test/update-modes.test.js b/packages/cli/test/update-modes.test.js index 98bb8d7..fa9c801 100644 --- a/packages/cli/test/update-modes.test.js +++ b/packages/cli/test/update-modes.test.js @@ -97,3 +97,24 @@ test('body modes are exclusive', () => { assert.equal(proc.status, 1); assert.match(proc.stderr, /one of --content, --append, --prepend/); }); + +test('a body with literal \\n escapes is written verbatim with a warning instead of being wiped', () => { + const flat = '# Goal\\nShip the release\\n\\n# Log\\n- 2026-09-01: drafted the plan with the team'; + const proc = spawnSync(process.execPath, [cli, 'update', 'p1', 't1', '--content', flat, '--json'], { + encoding: 'utf8', + env: { + ...process.env, + ATS_ADAPTER: adapterUrl, + ATS_CORPUS_CACHE_DISABLE: '1', + ATS_USAGE_DISABLE: '1', + ATS_FORMAT_SKIP_PROJECTS: '', + ATS_ACTION_LOG: path.join(tempDir, 'action-log-guard.jsonl'), + XDG_CONFIG_HOME: path.join(tempDir, 'xdg-guard'), + }, + }); + assert.equal(proc.status, 0, proc.stderr); + const out = JSON.parse(proc.stdout); + assert.equal((out.task || out).content, flat); + assert.match(proc.stderr, /literal \\n sequences/); + assert.match(proc.stderr, /body kept verbatim; normalizing would drop: .*drafted/); +}); diff --git a/packages/core/index.js b/packages/core/index.js index e698b40..8ad9109 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -110,5 +110,5 @@ export { scoreProgressEpisodes, formatProgressBenchmark, } from './progress-benchmark.js'; -export { normalizeTaskBody, contentHash, TRIAGE_TAG } from './task-format.js'; +export { normalizeTaskBody, hasLiteralNewlineEscapes, contentHash, TRIAGE_TAG } from './task-format.js'; export { stableDigest, buildReliabilitySnapshot } from './reliability-snapshot.js'; diff --git a/packages/core/task-format.js b/packages/core/task-format.js index 06ce434..5400c7e 100644 --- a/packages/core/task-format.js +++ b/packages/core/task-format.js @@ -71,7 +71,34 @@ function firstMeaningful(lines) { * (`- : `) when the task has no dated history of its own. * @returns {{content: string, changed: boolean, goal: string|null}} */ +const PLACEHOLDER_LINE = /^\s*::\s*todo\b.*set goal\s*::\s*$/gim; + +function wordTokens(text) { + return new Set((String(text).replace(PLACEHOLDER_LINE, '').toLowerCase().match(/[\p{L}\p{N}]+/gu) || [])); +} + +/** + * Normalize a body without losing text. Every word of the input must survive + * in the output; otherwise the body is returned verbatim with + * `skipped: 'content-loss'` and the missing words in `lost`. + */ export function normalizeTaskBody(content = '', opts = {}) { + const original = content || ''; + const result = normalizeUnchecked(original, opts); + if (!result.changed) return result; + const kept = wordTokens(result.content); + const lost = [...wordTokens(original)].filter((word) => !kept.has(word)); + if (!lost.length) return result; + return { content: original, changed: false, goal: null, skipped: 'content-loss', lost: lost.slice(0, 20) }; +} + +/** True when a one-line body carries literal `\n` escapes instead of line breaks. */ +export function hasLiteralNewlineEscapes(content = '') { + const text = String(content ?? ''); + return !text.includes('\n') && /\\n/.test(text); +} + +function normalizeUnchecked(content, opts) { const original = content || ''; const secs = splitSections(promoteHeadings(original)); diff --git a/packages/core/test/task-format.test.js b/packages/core/test/task-format.test.js index ddbbf38..eeee6b7 100644 --- a/packages/core/test/task-format.test.js +++ b/packages/core/test/task-format.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { normalizeTaskBody } from '../task-format.js'; +import { normalizeTaskBody, hasLiteralNewlineEscapes } from '../task-format.js'; test('lifts a buried goal: line to a wrapped # Goal at the top', () => { const input = 'preamble\n\ngoal: ship the normalizer\n\n- 2026-06-20: drafted spec\n- next: write core'; @@ -109,3 +109,25 @@ test('# Process re-run is a no-op (idempotent, frozen after a human edit)', () = assert.equal(twice.changed, false); assert.equal(twice.content, once); }); + +test('a body with literal \\n escapes is kept verbatim instead of collapsing to a skeleton', () => { + const flat = '# Goal\\nShip the release\\n\\n# Log\\n- 2026-09-01: drafted the plan with the team'; + assert.equal(hasLiteralNewlineEscapes(flat), true); + const out = normalizeTaskBody(flat); + assert.equal(out.content, flat); + assert.equal(out.changed, false); + assert.equal(out.skipped, 'content-loss'); + assert.ok(out.lost.includes('drafted')); +}); + +test('normalization never drops a word: a heading it would truncate keeps the body verbatim', () => { + const body = '## Goal for the third quarter\nship it\n'; + const out = normalizeTaskBody(body); + assert.equal(out.skipped, 'content-loss'); + assert.equal(out.content, body); + const ok = normalizeTaskBody('some prose\n- 2026-09-02: did a thing'); + assert.equal(ok.skipped, undefined); + assert.match(ok.content, /^# Goal\n::TODO — set goal::\n\n# Log\n- 2026-09-02: did a thing/); + assert.match(ok.content, /## Notes\nsome prose/); + assert.equal(hasLiteralNewlineEscapes('line one\nline two \\n'), false); +}); From 5110479119bc4e08ec6ca08b889fbcd1c5a08a55 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 8 Oct 2026 02:58:18 +0000 Subject: [PATCH 05/11] feat(review): separate the approver from the identity that staged an item review approve refuses a decision from the staging identity, by name or by the acting agent, with exit 4. ATS_REVIEW_REQUIRE_HUMAN=1 also refuses approvals from a process acting as an agent. Items record the staging and deciding actor, and approve/reject accept --note. --- packages/cli/bin/ats.js | 6 +++-- packages/cli/parser.js | 10 ++++++--- packages/cli/reliability.js | 3 +++ packages/cli/test/kg-governance.test.js | 26 +++++++++++++++++++++ packages/cli/test/kg.test.js | 2 +- packages/core/review-queue.js | 30 ++++++++++++++++++++++++- 6 files changed, 70 insertions(+), 7 deletions(-) diff --git a/packages/cli/bin/ats.js b/packages/cli/bin/ats.js index 356ca56..202f7e9 100755 --- a/packages/cli/bin/ats.js +++ b/packages/cli/bin/ats.js @@ -1091,6 +1091,7 @@ const summarizeReviewItem = (i) => ({ stagedAt: i.stagedAt, ...(i.note ? { note: i.note } : {}), ...(i.decidedBy ? { decidedBy: i.decidedBy } : {}), + ...(i.decisionNote ? { decisionNote: i.decisionNote } : {}), ...(i.applyError ? { applyError: i.applyError } : {}), }); @@ -1627,8 +1628,9 @@ async function handleReview() { } case 'approve': case 'reject': { - if (!args.positional.length) { console.error(`Usage: ats review ${args.subcommand} ID... [--by NAME]`); process.exit(1); } - const decided = args.positional.map((id) => decideReviewItem(id, args.subcommand, { by: args.options.by })); + if (!args.positional.length) { console.error(`Usage: ats review ${args.subcommand} ID... [--by NAME] [--note TEXT]`); process.exit(1); } + const note = args.options.note ?? args.options.reason; + const decided = args.positional.map((id) => decideReviewItem(id, args.subcommand, { by: args.options.by, note })); return { [args.subcommand === 'approve' ? 'approved' : 'rejected']: decided.map(summarizeReviewItem) }; } case 'apply': { diff --git a/packages/cli/parser.js b/packages/cli/parser.js index 50d3555..85cc8e1 100644 --- a/packages/cli/parser.js +++ b/packages/cli/parser.js @@ -30,7 +30,7 @@ const VALUE_OPTIONS = new Set([ 'format', 'content', 'append', 'prepend', 'title', 'project', 'projects', 'limit', 'budget-ms', 'timeout-ms', 'input', 'output', 'file', 'out', 'journal', 'if-match', 'idempotency-key', 'due', 'priority', 'tags', 'reminder', - 'domain', 'source', 'subject', 'predicate', 'confidence', 'task', 'by', 'agent', 'actor-kind', 'session', + 'domain', 'source', 'subject', 'predicate', 'confidence', 'task', 'by', 'agent', 'actor-kind', 'session', 'note', 'reason', 'url', 'type', 'desc', 'display', 'extract', 'folder', 'from', 'to', 'days', 'since', 'state', 'spool', 'interval', 'due-within-hours', 'max', 'threshold', 'max-corpus', 'rerank-depth', 'min-sources', 'facts-limit', @@ -778,10 +778,14 @@ undoable like any other write. Usage: ats review list [--all|--status S] Pending items (default) or all ats review show ID Full payload of one item - ats review approve ID... [--by NAME] Approve pending items - ats review reject ID... [--by NAME] Reject pending items + ats review approve ID... [--by NAME] [--note TEXT] Approve pending items + ats review reject ID... [--by NAME] [--note TEXT] Reject pending items ats review apply Execute approved writes +Approval has to come from an identity other than the one that staged the item +(exit 4 otherwise). ATS_REVIEW_REQUIRE_HUMAN=1 also refuses approvals from a +process acting as an agent. Each decision records the deciding actor and note. + Ids may be unambiguous prefixes. Task writes claim approved items before applying. Interrupted items stay applying; failed writes stay failed. Inspect the backend before staging a fresh proposal. Changed payloads or target revisions are refused.`; diff --git a/packages/cli/reliability.js b/packages/cli/reliability.js index 8377b3e..e67eb9a 100644 --- a/packages/cli/reliability.js +++ b/packages/cli/reliability.js @@ -119,6 +119,9 @@ export function withTimeout(promise, timeoutMs, label = 'operation') { export function classifyError(error) { const message = error?.message || String(error); const code = error?.code || 'ATS_ERROR'; + if (code === 'ATS_SEPARATION') { + return { kind: 'policy', code, retryable: false, exitCode: 4 }; + } if (error?.exitCode === 3 || /precondition|if-match|changed since/i.test(message)) { return { kind: 'precondition', code: 'ATS_PRECONDITION', retryable: true, exitCode: 3 }; } diff --git a/packages/cli/test/kg-governance.test.js b/packages/cli/test/kg-governance.test.js index 02ba824..cc2f9f2 100644 --- a/packages/cli/test/kg-governance.test.js +++ b/packages/cli/test/kg-governance.test.js @@ -95,3 +95,29 @@ test('kg verify rechecks task and file sources, exits 2 on a stale one and can s const pending = run(['kg', 'pending'], opts); assert.match(JSON.stringify(pending), new RegExp(`retract ${gone.slice(0, 8)}`)); }); + +test('review approve refuses the identity that staged the item and records the decision note', () => { + const opts = { store: 'separation' }; + const staged = run(['kg', 'propose', 'Ops team', 'owns', 'release calendar', '--source', 'task://p1/t1'], opts); + + const self = runProcess(['review', 'approve', staged.reviewId], opts); + assert.equal(self.status, 4, self.stdout + self.stderr); + assert.match(self.stdout + self.stderr, /staged by agent-test; approval has to come from another identity/); + const byName = runProcess(['review', 'approve', staged.reviewId, '--by', 'agent-test'], { ...opts, env: { ATS_AGENT_ID: '' } }); + assert.equal(byName.status, 4); + + const strict = runProcess(['review', 'approve', staged.reviewId], { ...opts, env: { ATS_AGENT_ID: 'second-agent', ATS_REVIEW_REQUIRE_HUMAN: '1' } }); + assert.equal(strict.status, 4); + assert.match(strict.stdout + strict.stderr, /needs a human approver/); + + const ok = run(['review', 'approve', staged.reviewId, '--note', 'matches the plan'], { ...opts, env: { ATS_AGENT_ID: '', ATS_ACTOR_KIND: 'human', ATS_REVIEW_REQUIRE_HUMAN: '1' } }); + assert.equal(ok.approved[0].decidedBy, 'reviewer'); + assert.equal(ok.approved[0].decisionNote, 'matches the plan'); + const shown = run(['review', 'show', staged.reviewId], opts); + assert.deepEqual(shown.decidedActor, { id: 'reviewer', kind: 'human' }); + assert.deepEqual(shown.stagedActor, { id: 'agent-test', kind: 'agent' }); + + const own = run(['kg', 'propose', 'Ops team', 'skips', 'retros', '--source', 'task://p1/t1'], opts); + const withdrawn = run(['review', 'reject', own.reviewId, '--note', 'withdrawn'], opts); + assert.equal(withdrawn.rejected[0].status, 'rejected'); +}); diff --git a/packages/cli/test/kg.test.js b/packages/cli/test/kg.test.js index cd84c7d..054c246 100644 --- a/packages/cli/test/kg.test.js +++ b/packages/cli/test/kg.test.js @@ -86,7 +86,7 @@ function run(argv, opts) { } function ratify(reviewId) { - run(['review', 'approve', reviewId]); + run(['review', 'approve', reviewId], { env: { ATS_AGENT_ID: '' } }); const out = run(['kg', 'ratify', reviewId]); assert.equal(out.ratified[0].ok, true, JSON.stringify(out)); return out.ratified[0]; diff --git a/packages/core/review-queue.js b/packages/core/review-queue.js index 37654e4..0d1e872 100644 --- a/packages/core/review-queue.js +++ b/packages/core/review-queue.js @@ -26,6 +26,7 @@ import { randomUUID } from 'node:crypto'; import { withLockSync, writeFileAtomicSync } from './fs-lock.js'; import { taskMetadataForRead } from './task-context.js'; import { stableDigest } from './reliability-snapshot.js'; +import { resolveActor } from './action-ledger.js'; export const REVIEW_QUEUE_VERSION = 1; @@ -74,6 +75,7 @@ export function stageReviewItem({ kind, payload, note, by }, { queuePath = revie payload, note: note || null, stagedBy: by || process.env.ATS_AGENT_ID || 'unknown-agent', + stagedActor: resolveActor(by ? { agent: by } : {}), stagedAt: new Date().toISOString(), status: 'pending', }; @@ -95,15 +97,41 @@ export function findReviewItem(idOrPrefix, { queuePath = reviewQueuePath() } = { return matchItem(readReviewQueue({ queuePath }).items, idOrPrefix); } +function separationError(message) { + const error = new Error(message); + error.code = 'ATS_SEPARATION'; + error.exitCode = 4; + return error; +} + +/** + * Approvals come from someone other than the stager. With + * ATS_REVIEW_REQUIRE_HUMAN=1 they must also come from a human actor. + * Rejecting one's own proposal is always allowed. + */ +function assertSeparateApprover(item, decidedBy, actor) { + const same = (a, b) => typeof a === 'string' && typeof b === 'string' && a.trim().toLowerCase() === b.trim().toLowerCase(); + if (same(decidedBy, item.stagedBy) || (actor.kind === 'agent' && same(actor.id, item.stagedBy))) { + throw separationError(`Review item ${item.id} was staged by ${item.stagedBy}; approval has to come from another identity.`); + } + if (process.env.ATS_REVIEW_REQUIRE_HUMAN === '1' && actor.kind !== 'human') { + throw separationError(`Review item ${item.id} needs a human approver (ATS_REVIEW_REQUIRE_HUMAN=1); this process acts as ${actor.kind} ${actor.id}.`); + } +} + export function decideReviewItem(idOrPrefix, decision, { by, note, queuePath = reviewQueuePath() } = {}) { if (!['approve', 'reject'].includes(decision)) throw new Error(`Unknown review decision: ${decision}`); return withLockSync(queuePath, () => { const queue = readReviewQueue({ queuePath }); const item = matchItem(queue.items, idOrPrefix); if (item.status !== 'pending') throw new Error(`Review item ${item.id} is ${item.status}, not pending.`); + const decidedBy = by || process.env.ATS_REVIEWER || process.env.USER || 'reviewer'; + const actor = resolveActor(); + if (decision === 'approve') assertSeparateApprover(item, decidedBy, actor); item.status = decision === 'approve' ? 'approved' : 'rejected'; if (decision === 'approve') item.approvedDigest = stableDigest({ kind: item.kind, payload: item.payload }); - item.decidedBy = by || process.env.ATS_REVIEWER || process.env.USER || 'reviewer'; + item.decidedBy = decidedBy; + item.decidedActor = actor; item.decidedAt = new Date().toISOString(); if (note) item.decisionNote = note; writeQueue(queue, queuePath); From 30481752a52b63a9431d988c6da9570b4c93c97b Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 8 Oct 2026 03:01:06 +0000 Subject: [PATCH 06/11] feat(kg): require a source on fact proposals by policy kg propose --require-source any|checkable, or ATS_KG_REQUIRE_SOURCE with an optional ATS_KG_REQUIRE_SOURCE_DOMAINS list, refuses a proposal without a source with verdict unsourced and exit 4. checkable accepts the task, file and URL references kg verify can recheck. Batch proposals apply the same policy per line. --- packages/cli/bin/ats.js | 2 + packages/cli/parser.js | 7 +++- packages/cli/test/kg-governance.test.js | 33 ++++++++++++++- packages/core/kg-store.js | 54 ++++++++++++++++++++++++- packages/core/kg-verify.js | 18 +-------- packages/core/test/kg-verify.test.js | 2 +- 6 files changed, 93 insertions(+), 23 deletions(-) diff --git a/packages/cli/bin/ats.js b/packages/cli/bin/ats.js index 202f7e9..0ac0547 100755 --- a/packages/cli/bin/ats.js +++ b/packages/cli/bin/ats.js @@ -1197,6 +1197,7 @@ async function handleKg() { const text = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8'); const report = proposeFactLines(text.split('\n'), { by: agentId, + requireSource: args.options['require-source'], defaults: { domain: args.options.domain, source: args.options.source, confidence: args.options.confidence, validAt: args.options['valid-at'], learnedAt: args.options['learned-at'] }, }); const ok = report.refused === 0 && report.invalid === 0; @@ -1232,6 +1233,7 @@ async function handleKg() { supersedes: args.options.supersedes, additive: !!args.options.additive, acknowledgeRejected: args.options['acknowledge-rejected'], + requireSource: args.options['require-source'], }); } catch (err) { if (err instanceof KgGateError) return kgGateOutcome(err); diff --git a/packages/cli/parser.js b/packages/cli/parser.js index 85cc8e1..d329ecd 100644 --- a/packages/cli/parser.js +++ b/packages/cli/parser.js @@ -30,7 +30,7 @@ const VALUE_OPTIONS = new Set([ 'format', 'content', 'append', 'prepend', 'title', 'project', 'projects', 'limit', 'budget-ms', 'timeout-ms', 'input', 'output', 'file', 'out', 'journal', 'if-match', 'idempotency-key', 'due', 'priority', 'tags', 'reminder', - 'domain', 'source', 'subject', 'predicate', 'confidence', 'task', 'by', 'agent', 'actor-kind', 'session', 'note', + 'domain', 'source', 'subject', 'predicate', 'confidence', 'task', 'by', 'agent', 'actor-kind', 'session', 'note', 'require-source', 'reason', 'url', 'type', 'desc', 'display', 'extract', 'folder', 'from', 'to', 'days', 'since', 'state', 'spool', 'interval', 'due-within-hours', 'max', 'threshold', 'max-corpus', 'rerank-depth', 'min-sources', 'facts-limit', @@ -802,10 +802,13 @@ every fact records who did both and from what source. Usage: ats kg propose SUBJ PRED OBJ [--domain D --source REF --confidence C --task P/T] [--supersedes FACT_ID | --additive] [--acknowledge-rejected ID] - [--valid-at ISO --learned-at ISO] + [--valid-at ISO --learned-at ISO] [--require-source any|checkable] ats kg propose --file FILE|- One JSON object per line, every line through the gate; --domain/--source/ --confidence fill what a line lacks + ATS_KG_REQUIRE_SOURCE=any|checkable (optionally + per ATS_KG_REQUIRE_SOURCE_DOMAINS) refuses + unsourced proposals with exit 4 ats kg stale [--days N --domain D] Active facts due for evidence review ats kg confirm FACT_ID --source REF Reviewed evidence confirmation ats kg verify [FACT_ID...] [--domain D] Recheck each fact's source: task://P/T and diff --git a/packages/cli/test/kg-governance.test.js b/packages/cli/test/kg-governance.test.js index cc2f9f2..8b28382 100644 --- a/packages/cli/test/kg-governance.test.js +++ b/packages/cli/test/kg-governance.test.js @@ -36,10 +36,11 @@ after(() => { fs.rmSync(tempDir, { recursive: true, force: true }); }); -function runProcess(argv, { env = {}, store = 'main' } = {}) { +function runProcess(argv, { env = {}, store = 'main', input } = {}) { const dir = path.join(tempDir, store); return spawnSync(process.execPath, [cli, ...argv, '--json'], { encoding: 'utf8', + input, env: { ...process.env, ATS_ADAPTER: adapterUrl, @@ -121,3 +122,33 @@ test('review approve refuses the identity that staged the item and records the d const withdrawn = run(['review', 'reject', own.reviewId, '--note', 'withdrawn'], opts); assert.equal(withdrawn.rejected[0].status, 'rejected'); }); + +test('a provenance policy refuses unsourced proposals with exit 4, per call or per domain', () => { + const opts = { store: 'policy' }; + const flag = runProcess(['kg', 'propose', 'Ops team', 'prefers', 'async standups', '--require-source', 'any'], opts); + assert.equal(flag.status, 4, flag.stderr); + assert.equal(JSON.parse(flag.stdout).verdict, 'unsourced'); + + const env = { ATS_KG_REQUIRE_SOURCE: 'checkable', ATS_KG_REQUIRE_SOURCE_DOMAINS: 'sales' }; + const opaque = runProcess(['kg', 'propose', 'Acme', 'buys', 'support plan', '--domain', 'sales', '--source', 'call notes'], { ...opts, env }); + assert.equal(opaque.status, 4); + assert.match(JSON.parse(opaque.stdout).message, /checkable source/); + const checkable = run(['kg', 'propose', 'Acme', 'buys', 'support plan', '--domain', 'sales', '--source', 'https://example.com/order/1'], { ...opts, env }); + assert.equal(checkable.staged, true); + const otherDomain = run(['kg', 'propose', 'Ops team', 'prefers', 'async standups', '--domain', 'ops'], { ...opts, env }); + assert.equal(otherDomain.staged, true); + + const batch = runProcess(['kg', 'propose', '--file', '-', '--domain', 'sales'], { + ...opts, + env, + input: [ + JSON.stringify({ subject: 'Acme', predicate: 'renews', object: 'in March', task: 'p1/t1' }), + JSON.stringify({ subject: 'Acme', predicate: 'pays', object: 'net 30' }), + ].join('\n'), + }); + assert.equal(batch.status, 4); + const report = JSON.parse(batch.stdout); + assert.equal(report.staged, 1); + assert.equal(report.refused, 1); + assert.equal(report.results[1].verdict, 'unsourced'); +}); diff --git a/packages/core/kg-store.js b/packages/core/kg-store.js index 40678cb..548a3f8 100644 --- a/packages/core/kg-store.js +++ b/packages/core/kg-store.js @@ -149,6 +149,53 @@ export class KgGateError extends Error { } } +/** Classify a fact's source reference. */ +export function classifySource(fact = {}) { + const source = typeof fact.provenance?.source === 'string' ? fact.provenance.source.trim() : ''; + if (fact.taskRef?.projectId && fact.taskRef?.taskId) { + return { kind: 'task', projectId: fact.taskRef.projectId, taskId: fact.taskRef.taskId, ref: `task://${fact.taskRef.projectId}/${fact.taskRef.taskId}` }; + } + if (!source) return { kind: 'none', ref: null }; + const task = /^task:\/\/([^/\s]+)\/([^/\s]+)$/.exec(source); + if (task) return { kind: 'task', projectId: task[1], taskId: task[2], ref: source }; + if (/^https?:\/\//i.test(source)) return { kind: 'url', url: source, ref: source }; + const file = /^file:(?:\/\/)?(.+)$/.exec(source); + if (file) return { kind: 'file', file: file[1], ref: source }; + if (source.startsWith('/') || source.startsWith('./') || source.startsWith('../')) return { kind: 'file', file: source, ref: source }; + return { kind: 'opaque', ref: source }; +} + +const CHECKABLE_SOURCES = new Set(['task', 'file', 'url']); + +/** + * Provenance policy for proposals: `any` needs a non-empty source, `checkable` + * a task, file or URL reference that `kg verify` can recheck. Read from + * ATS_KG_REQUIRE_SOURCE (1/any/checkable), optionally limited to the domains + * in ATS_KG_REQUIRE_SOURCE_DOMAINS. + */ +export function sourcePolicy(domain, { requireSource, env = process.env } = {}) { + let mode = requireSource ?? env.ATS_KG_REQUIRE_SOURCE ?? ''; + mode = String(mode).trim().toLowerCase(); + if (!mode || mode === '0' || mode === 'off') return null; + if (mode === '1' || mode === 'true') mode = 'any'; + if (mode !== 'any' && mode !== 'checkable') throw new Error(`kg: require-source must be any or checkable, got "${mode}".`); + const domains = String(env.ATS_KG_REQUIRE_SOURCE_DOMAINS || '').split(',').map((d) => d.trim()).filter(Boolean); + if (requireSource === undefined && domains.length && !domains.includes(domain)) return null; + return mode; +} + +function checkSourcePolicy(payload, mode) { + if (!mode) return null; + const src = classifySource({ taskRef: payload.taskRef, provenance: { source: payload.source } }); + if (src.kind === 'none') { + return { verdict: 'unsourced', message: `kg: the ${payload.domain} domain requires a source on every proposal (--source REF or --task PROJECT/TASK).`, policy: mode }; + } + if (mode === 'checkable' && !CHECKABLE_SOURCES.has(src.kind)) { + return { verdict: 'unsourced', message: `kg: the ${payload.domain} domain requires a checkable source (task://PROJECT/TASK, --task, file:PATH or an http(s) URL); got "${src.ref}".`, policy: mode }; + } + return null; +} + /** * The proposal gate — pure, run before anything is staged. A proposal is * checked against the folded store and the review queue and gets one verdict: @@ -222,7 +269,7 @@ export function checkFactProposal(proposal, { facts = [], reviewItems = [], supe * ratification; `additive` allows a second value for the same * subject+predicate; `acknowledgeRejected` re-opens a triple a human declined. */ -export function proposeFact({ subject, predicate, object, domain, source, confidence, taskRef, by, supersedes, additive, acknowledgeRejected, validAt, learnedAt } = {}, { queuePath, factsPath } = {}) { +export function proposeFact({ subject, predicate, object, domain, source, confidence, taskRef, by, supersedes, additive, acknowledgeRejected, validAt, learnedAt, requireSource } = {}, { queuePath, factsPath } = {}) { const payload = { op: 'add', ...(validAt !== undefined ? { validAt: factTimestamp(validAt, 'validAt') } : {}), @@ -243,6 +290,8 @@ export function proposeFact({ subject, predicate, object, domain, source, confid payload.supersedes = target.id; } if (additive) payload.additive = true; + const unsourced = checkSourcePolicy(payload, sourcePolicy(payload.domain, { requireSource })); + if (unsourced) throw new KgGateError(unsourced); const reviewItems = listReviewItems({ kind: 'kg.fact', ...(queuePath ? { queuePath } : {}) }); const gate = checkFactProposal(payload, { facts, reviewItems, supersedes: payload.supersedes || null, additive: !!additive, acknowledgeRejected }); if (gate.verdict !== 'clear') throw new KgGateError(gate); @@ -268,7 +317,7 @@ function parseTaskRef(value) { * is caught against the line that was staged just before it. `defaults` * (domain, source, confidence) fill in what a line does not carry. */ -export function proposeFactLines(lines, { by, defaults = {}, queuePath, factsPath } = {}) { +export function proposeFactLines(lines, { by, defaults = {}, requireSource, queuePath, factsPath } = {}) { const results = []; const counts = { staged: 0, duplicate: 0, refused: 0, invalid: 0 }; const paths = { ...(queuePath ? { queuePath } : {}), ...(factsPath ? { factsPath } : {}) }; @@ -299,6 +348,7 @@ export function proposeFactLines(lines, { by, defaults = {}, queuePath, factsPat supersedes: input.supersedes, additive: !!input.additive, acknowledgeRejected: input.acknowledgeRejected, + requireSource, }, paths); counts.staged += 1; results.push({ line, ok: true, reviewId: item.id, ...(item.payload.supersedes ? { supersedes: item.payload.supersedes } : {}) }); diff --git a/packages/core/kg-verify.js b/packages/core/kg-verify.js index dd9c649..c2af3c0 100644 --- a/packages/core/kg-verify.js +++ b/packages/core/kg-verify.js @@ -9,26 +9,10 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { listKgFacts } from './kg-store.js'; +import { classifySource, listKgFacts } from './kg-store.js'; const NOT_FOUND = /not\s*found|no such|does not exist|404|410|deleted/i; -/** Classify a fact's source reference. */ -export function classifySource(fact = {}) { - const source = typeof fact.provenance?.source === 'string' ? fact.provenance.source.trim() : ''; - if (fact.taskRef?.projectId && fact.taskRef?.taskId) { - return { kind: 'task', projectId: fact.taskRef.projectId, taskId: fact.taskRef.taskId, ref: `task://${fact.taskRef.projectId}/${fact.taskRef.taskId}` }; - } - if (!source) return { kind: 'none', ref: null }; - const task = /^task:\/\/([^/\s]+)\/([^/\s]+)$/.exec(source); - if (task) return { kind: 'task', projectId: task[1], taskId: task[2], ref: source }; - if (/^https?:\/\//i.test(source)) return { kind: 'url', url: source, ref: source }; - const file = /^file:(?:\/\/)?(.+)$/.exec(source); - if (file) return { kind: 'file', file: file[1], ref: source }; - if (source.startsWith('/') || source.startsWith('./') || source.startsWith('../')) return { kind: 'file', file: source, ref: source }; - return { kind: 'opaque', ref: source }; -} - async function checkTask(src, getTask) { if (typeof getTask !== 'function') return { status: 'unverifiable', reason: 'no adapter to read task sources' }; try { diff --git a/packages/core/test/kg-verify.test.js b/packages/core/test/kg-verify.test.js index 76fe4f8..dce7ad4 100644 --- a/packages/core/test/kg-verify.test.js +++ b/packages/core/test/kg-verify.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { classifySource, verifyFacts } from '../kg-verify.js'; +import { classifySource, verifyFacts } from '../kg.js'; function writeFacts(dir, facts) { const factsPath = path.join(dir, 'kg-facts.jsonl'); From 5deb0009e10117b372be2392b1071674b66654b8 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 8 Oct 2026 03:06:07 +0000 Subject: [PATCH 07/11] fix(obsidian,okf): patch frontmatter in place Note and concept updates rewrite only the keys they change. Block lists, nested maps, comments, key case and unknown keys stay byte for byte; values that need it are quoted, and missing keys are appended. --- .../adapter-obsidian/test/obsidian.test.js | 31 +++++++++++ packages/adapter-obsidian/vault.js | 49 +++++++++++++++-- packages/adapter-okf/bundle.js | 54 +++++++++++++++---- packages/adapter-okf/test/okf.test.js | 19 +++++++ 4 files changed, 139 insertions(+), 14 deletions(-) diff --git a/packages/adapter-obsidian/test/obsidian.test.js b/packages/adapter-obsidian/test/obsidian.test.js index d3cbd74..78987ec 100644 --- a/packages/adapter-obsidian/test/obsidian.test.js +++ b/packages/adapter-obsidian/test/obsidian.test.js @@ -328,3 +328,34 @@ test('createTask rejects a ../ projectId instead of writing outside the vault', cleanup(dir); } }); + +test('patchNote rewrites only the patched keys and keeps the rest of the frontmatter byte for byte', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ats-obsidian-fm-')); + try { + const file = path.join(dir, 'Plan.md'); + const untouched = [ + 'Status: open', + 'aliases:', + ' - Alpha', + ' - Beta', + 'nested:', + ' owner: team', + '# a comment the user keeps', + 'cssclass: wide', + ]; + fs.writeFileSync(file, ['---', 'title: Old', ...untouched, 'tags: [a, b]', '---', 'body text', ''].join('\n')); + vault.patchNote(dir, 'Plan', { title: 'Plan: phase 2', tags: ['x', 'y'] }); + const raw = fs.readFileSync(file, 'utf8'); + assert.equal(raw, ['---', 'title: "Plan: phase 2"', ...untouched, 'tags: [x, y]', '---', 'body text', ''].join('\n')); + const read = vault.readNote(dir, file); + assert.equal(read.title, 'Plan: phase 2'); + assert.deepEqual(read.tags, ['x', 'y']); + + vault.patchNote(dir, 'Plan', { dueDate: '2026-10-20', content: 'new body\n' }); + const again = fs.readFileSync(file, 'utf8'); + assert.match(again, /\ncssclass: wide\ntags: \[x, y\]\ndue: 2026-10-20\n---\nnew body\n$/); + assert.ok(untouched.every((line) => again.includes(`${line}\n`))); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/adapter-obsidian/vault.js b/packages/adapter-obsidian/vault.js index 38dc0a3..80d7f6c 100644 --- a/packages/adapter-obsidian/vault.js +++ b/packages/adapter-obsidian/vault.js @@ -154,6 +154,43 @@ export function serializeFrontmatter(data) { return `---\n${lines.join('\n')}\n---\n`; } +/** + * Rewrite only the named keys of a leading frontmatter block and keep every + * other line byte for byte (block lists, nested maps, comments, key case). + * `updates` maps key → value; keys match case-insensitively and a missing key + * is appended. Returns the new frontmatter block (through its closing `---` + * line), or null when the text has no frontmatter. + */ +export function patchFrontmatterBlock(raw, updates, renderEntry) { + const m = /^---(\r?\n)([\s\S]*?)\r?\n---(\r?\n|$)/.exec(raw); + if (!m) return null; + const eol = m[1]; + const lines = m[2].split(/\r?\n/); + const pending = new Map(Object.entries(updates) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key.toLowerCase(), { key, value }])); + const out = []; + for (let i = 0; i < lines.length; i += 1) { + const kv = /^([A-Za-z0-9_-]+)\s*:/.exec(lines[i]); + const hit = kv && pending.get(kv[1].toLowerCase()); + if (!hit) { out.push(lines[i]); continue; } + while (i + 1 < lines.length && /^(\s+\S|\s*-(\s|$))/.test(lines[i + 1])) i += 1; + out.push(...renderEntry(kv[1], hit.value)); + pending.delete(kv[1].toLowerCase()); + } + for (const { key, value } of pending.values()) out.push(...renderEntry(key, value)); + return { block: `---${eol}${out.join(eol)}${eol}---${m[3] || eol}`, body: raw.slice(m[0].length) }; +} + +const NEEDS_QUOTES = /^[\s[\]{}"'#&*!|>%@`,?:-]|:\s|\s#|\s$/; + +function renderInlineEntry(key, value) { + if (Array.isArray(value)) return [`${key}: [${value.join(', ')}]`]; + const text = String(value); + if (!NEEDS_QUOTES.test(text)) return [`${key}: ${text}`]; + return [`${key}: ${text.includes('"') ? `'${text.replace(/'/g, "''")}'` : `"${text}"`}`]; +} + /** Tags from frontmatter (`tags: [a, b]` / `tags: a, b`) plus inline `#tag`s. */ export function extractTags(data, body) { const set = new Set(); @@ -237,12 +274,14 @@ export function patchNote(vaultDir, taskId, patch = {}) { const raw = fs.readFileSync(abs, 'utf8'); const { data, body } = parseFrontmatter(raw); - const newData = { ...data }; - if (patch.title !== undefined) newData.title = patch.title; - if (patch.tags !== undefined) newData.tags = normalizeTags(patch.tags); - if (patch.dueDate !== undefined) newData.due = patch.dueDate; + const updates = {}; + if (patch.title !== undefined) updates.title = patch.title; + if (patch.tags !== undefined) updates.tags = normalizeTags(patch.tags); + if (patch.dueDate !== undefined) updates.due = patch.dueDate; const newBody = patch.content !== undefined ? patch.content : body; - fs.writeFileSync(abs, `${serializeFrontmatter(newData)}${newBody}`); + const patched = patchFrontmatterBlock(raw, updates, renderInlineEntry); + const front = patched ? patched.block : serializeFrontmatter({ ...data, ...updates }); + fs.writeFileSync(abs, `${front}${newBody}`); return readNote(vaultDir, abs); } diff --git a/packages/adapter-okf/bundle.js b/packages/adapter-okf/bundle.js index a78be79..736ed38 100644 --- a/packages/adapter-okf/bundle.js +++ b/packages/adapter-okf/bundle.js @@ -172,6 +172,40 @@ function serializeScalar(value) { return String(value); } +/** + * Rewrite only the named keys of a leading frontmatter block and keep every + * other line byte for byte (block lists, nested maps, comments, key case). + * `updates` maps key → value; keys match case-insensitively and a missing key + * is appended. Returns the new frontmatter block (through its closing `---` + * line), or null when the text has no frontmatter. + */ +export function patchFrontmatterBlock(raw, updates, renderEntry) { + const m = /^---(\r?\n)([\s\S]*?)\r?\n---(\r?\n|$)/.exec(raw); + if (!m) return null; + const eol = m[1]; + const lines = m[2].split(/\r?\n/); + const pending = new Map(Object.entries(updates) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key.toLowerCase(), { key, value }])); + const out = []; + for (let i = 0; i < lines.length; i += 1) { + const kv = /^([A-Za-z0-9_-]+)\s*:/.exec(lines[i]); + const hit = kv && pending.get(kv[1].toLowerCase()); + if (!hit) { out.push(lines[i]); continue; } + while (i + 1 < lines.length && /^(\s+\S|\s*-(\s|$))/.test(lines[i + 1])) i += 1; + out.push(...renderEntry(kv[1], hit.value)); + pending.delete(kv[1].toLowerCase()); + } + for (const { key, value } of pending.values()) out.push(...renderEntry(key, value)); + return { block: `---${eol}${out.join(eol)}${eol}---${m[3] || eol}`, body: raw.slice(m[0].length) }; +} + +function renderBlockEntry(key, value) { + const v = serializeScalar(value); + if (!Array.isArray(v)) return [`${key}: ${v}`]; + return [`${key}:`, ...v.map((item) => `- ${item}`)]; +} + export function serializeFrontmatter(data) { const keys = Object.keys(data).filter((k) => data[k] !== undefined); const lines = []; @@ -307,16 +341,18 @@ export function patchConcept(bundleDir, taskId, patch = {}) { const raw = fs.readFileSync(abs, 'utf8'); const { data, body } = parseFrontmatter(raw); - const newData = { ...data }; - if (!newData.type) newData.type = 'Task'; - if (patch.title !== undefined) newData.title = patch.title; - if (patch.tags !== undefined) newData.tags = normalizeTags(patch.tags); - if (patch.type !== undefined) newData.type = patch.type; - if (patch.description !== undefined) newData.description = patch.description; - if (patch.resource !== undefined) newData.resource = patch.resource; - newData.timestamp = nowIso(); + const updates = {}; + if (!data.type) updates.type = 'Task'; + if (patch.title !== undefined) updates.title = patch.title; + if (patch.tags !== undefined) updates.tags = normalizeTags(patch.tags); + if (patch.type !== undefined) updates.type = patch.type; + if (patch.description !== undefined) updates.description = patch.description; + if (patch.resource !== undefined) updates.resource = patch.resource; + updates.timestamp = nowIso(); const newBody = patch.content !== undefined ? patch.content : body; - fs.writeFileSync(abs, `${serializeFrontmatter(newData)}${newBody}`); + const patched = patchFrontmatterBlock(raw, updates, renderBlockEntry); + const front = patched ? patched.block : serializeFrontmatter({ ...data, ...updates }); + fs.writeFileSync(abs, `${front}${newBody}`); return readConcept(bundleDir, abs); } diff --git a/packages/adapter-okf/test/okf.test.js b/packages/adapter-okf/test/okf.test.js index 4c222a5..174f105 100644 --- a/packages/adapter-okf/test/okf.test.js +++ b/packages/adapter-okf/test/okf.test.js @@ -180,3 +180,22 @@ test('core find() retrieves over the OKF bundle with provenance', async () => { cleanup(dir); } }); + +test('patchConcept keeps unknown keys, block lists and nested maps while it updates the patched ones', async () => { + const bundle = await import('../bundle.js'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ats-okf-fm-')); + try { + const file = path.join(dir, 'concept.md'); + const kept = ['type: Task', 'aliases:', '- first', '- second', 'meta:', ' owner: team', 'Custom-Key: Value']; + fs.writeFileSync(file, ['---', 'title: Old', ...kept, 'tags:', '- a', 'timestamp: 2026-01-01T00:00:00Z', '---', '', 'body', ''].join('\n')); + bundle.patchConcept(dir, 'concept', { title: 'New', tags: ['b', 'c'] }); + const raw = fs.readFileSync(file, 'utf8'); + assert.ok(raw.startsWith(['---', 'title: New', ...kept, 'tags:', '- b', '- c', 'timestamp: '].join('\n'))); + assert.match(raw, /\n---\n\nbody\n$/); + const read = bundle.parseFrontmatter(raw).data; + assert.deepEqual(read.aliases, ['first', 'second']); + assert.equal(read['Custom-Key'], 'Value'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 97b93ade992462115d168976c70545e6481e9cb0 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 8 Oct 2026 03:08:42 +0000 Subject: [PATCH 08/11] feat(ledger): chain action records and verify the chain Every ledger record carries prevHash, the SHA-256 of the line before it. ats ledger verify checks the chain, reports each break with its line and exits 2; it prints head, the hash of the last entry, and --expect-head compares it with a value kept elsewhere. Entries written before chaining stay valid at the start of the file. --- packages/cli/bin/ats.js | 9 +++ packages/cli/parser.js | 9 ++- packages/cli/test/kg-governance.test.js | 21 +++++++ packages/core/action-ledger.d.ts | 11 ++++ packages/core/action-ledger.js | 74 +++++++++++++++++++++++++ packages/core/index.js | 2 +- packages/core/test/ledger-chain.test.js | 68 +++++++++++++++++++++++ 7 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 packages/core/test/ledger-chain.test.js diff --git a/packages/cli/bin/ats.js b/packages/cli/bin/ats.js index 0ac0547..dfbdf62 100755 --- a/packages/cli/bin/ats.js +++ b/packages/cli/bin/ats.js @@ -71,6 +71,7 @@ import { contextForTask, recordAction, resolveActor, + verifyLedger, listActions, snapshotTask, taskHistory, @@ -2512,6 +2513,14 @@ async function handleLedger() { advanced: booleanOption(args.options.advanced, 'advanced') ?? false, }); } + if (args.subcommand === 'verify') { + const report = verifyLedger({ expectHead: args.options['expect-head'] }); + if (!report.ok) { + console.log(formatOutput(report, args.options.format)); + process.exit(2); + } + return report; + } if (args.subcommand === 'list') { return listActions({ projectId: args.options.project, diff --git a/packages/cli/parser.js b/packages/cli/parser.js index d329ecd..77563d8 100644 --- a/packages/cli/parser.js +++ b/packages/cli/parser.js @@ -30,7 +30,7 @@ const VALUE_OPTIONS = new Set([ 'format', 'content', 'append', 'prepend', 'title', 'project', 'projects', 'limit', 'budget-ms', 'timeout-ms', 'input', 'output', 'file', 'out', 'journal', 'if-match', 'idempotency-key', 'due', 'priority', 'tags', 'reminder', - 'domain', 'source', 'subject', 'predicate', 'confidence', 'task', 'by', 'agent', 'actor-kind', 'session', 'note', 'require-source', + 'domain', 'source', 'subject', 'predicate', 'confidence', 'task', 'by', 'agent', 'actor-kind', 'session', 'note', 'require-source', 'expect-head', 'reason', 'url', 'type', 'desc', 'display', 'extract', 'folder', 'from', 'to', 'days', 'since', 'state', 'spool', 'interval', 'due-within-hours', 'max', 'threshold', 'max-corpus', 'rerank-depth', 'min-sources', 'facts-limit', @@ -660,6 +660,7 @@ matches on its title and intent as \`related\` — each with provenance. Usage: ats ledger record PROJECT_ID TASK_ID --action NAME [options] ats ledger list [options] + ats ledger verify [--expect-head HASH] Record options: --agent Agent identity (default ATS_AGENT_ID or ats-cli) @@ -673,7 +674,11 @@ List filters: --project, --task, --agent, --actor-kind agent|human|unattributed, Every record carries actor: { id, kind, session? }. kind is agent when --agent or ATS_AGENT_ID names one, human for ATS_ACTOR_KIND=human or an interactive terminal, -otherwise unattributed. ATS_SESSION_ID binds the session.`, +otherwise unattributed. ATS_SESSION_ID binds the session. + +Every record carries prevHash, the SHA-256 of the previous line. verify checks the +chain, reports breaks (exit 2) and prints head, the hash of the last entry; keep +head elsewhere and pass it as --expect-head to detect a truncated ledger.`, security: `ats security - Portable task access policy and audited decisions Usage: diff --git a/packages/cli/test/kg-governance.test.js b/packages/cli/test/kg-governance.test.js index 8b28382..4f915df 100644 --- a/packages/cli/test/kg-governance.test.js +++ b/packages/cli/test/kg-governance.test.js @@ -152,3 +152,24 @@ test('a provenance policy refuses unsourced proposals with exit 4, per call or p assert.equal(report.refused, 1); assert.equal(report.results[1].verdict, 'unsourced'); }); + +test('ledger verify accepts the chained ledger and exits 2 once an entry was edited', () => { + const opts = { store: 'ledger' }; + run(['ledger', 'record', 'p1', 't1', '--action', 'note.added', '--output', 'first'], opts); + run(['ledger', 'record', 'p1', 't1', '--action', 'note.added', '--output', 'second'], opts); + run(['ledger', 'record', 'p1', 't1', '--action', 'note.added', '--output', 'third'], opts); + const ok = run(['ledger', 'verify'], opts); + assert.equal(ok.ok, true); + assert.equal(ok.chained, 3); + const listed = run(['ledger', 'list', '--actor-kind', 'agent'], opts); + assert.equal(listed.length, 3); + assert.deepEqual(listed[0].actor, { id: 'agent-test', kind: 'agent' }); + + const logPath = path.join(tempDir, 'ledger', 'action-log.jsonl'); + fs.writeFileSync(logPath, fs.readFileSync(logPath, 'utf8').replace('"first"', '"edited"')); + const broken = runProcess(['ledger', 'verify'], opts); + assert.equal(broken.status, 2); + assert.equal(JSON.parse(broken.stdout).breaks[0].line, 2); + const head = runProcess(['ledger', 'verify', '--expect-head', ok.head], opts); + assert.equal(head.status, 2); +}); diff --git a/packages/core/action-ledger.d.ts b/packages/core/action-ledger.d.ts index 52376e5..eb3a27a 100644 --- a/packages/core/action-ledger.d.ts +++ b/packages/core/action-ledger.d.ts @@ -28,6 +28,7 @@ export interface ActionLedgerRecord extends ActionLedgerEntry { ts: string; agent: string; actor: ActionActor; + prevHash?: string | null; sources: string[]; approvals: string[]; advanced: boolean; @@ -48,6 +49,16 @@ export interface RevertResult { } export function actionLogPath(): string; +export interface LedgerVerification { + ok: boolean; + entries: number; + chained: number; + unchained: number; + breaks: Array<{ line: number; id?: string; reason: string; expected?: string | null; actual?: string | null }>; + head: string | null; + headMatches?: boolean; +} +export function verifyLedger(options?: { logPath?: string; expectHead?: string }): LedgerVerification; export function resolveActor(options?: { agent?: string; kind?: ActionActor['kind']; session?: string; env?: Record; interactive?: boolean }): ActionActor; export function recordAction(entry: ActionLedgerEntry, options?: { logPath?: string }): ActionLedgerRecord | null; export function listActions(filters?: { agent?: string; actorKind?: ActionActor['kind']; session?: string; action?: string; projectId?: string; taskId?: string; advanced?: boolean; limit?: number }, options?: { logPath?: string }): ActionLedgerRecord[]; diff --git a/packages/core/action-ledger.js b/packages/core/action-ledger.js index b2ee99a..c7e985c 100644 --- a/packages/core/action-ledger.js +++ b/packages/core/action-ledger.js @@ -97,12 +97,86 @@ function buildRecord(entry) { return record; } +const lineHash = (line) => createHash('sha256').update(line, 'utf8').digest('hex'); + +/** The last non-empty line of a file, read from its tail. */ +function readLastLine(logPath) { + let fd; + try { fd = fs.openSync(logPath, 'r'); } catch (err) { + if (err.code === 'ENOENT') return null; + throw err; + } + try { + const size = fs.fstatSync(fd).size; + let chunk = 64 * 1024; + while (true) { + const start = Math.max(0, size - chunk); + const buf = Buffer.alloc(size - start); + fs.readSync(fd, buf, 0, buf.length, start); + const text = buf.toString('utf8').replace(/\n+$/, ''); + const nl = text.lastIndexOf('\n'); + if (nl >= 0 || start === 0) return text.slice(nl + 1) || null; + chunk *= 4; + } + } finally { + fs.closeSync(fd); + } +} + +// Every appended record carries prevHash, the SHA-256 of the exact previous +// line, so an edited, removed or reordered entry breaks the chain. function appendRecordUnlocked(record, logPath) { fs.mkdirSync(path.dirname(logPath), { recursive: true, mode: 0o700 }); + const last = readLastLine(logPath); + record.prevHash = last === null ? null : lineHash(last); fs.appendFileSync(logPath, JSON.stringify(record) + '\n', { mode: 0o600 }); fs.chmodSync(logPath, 0o600); } +/** + * Check the ledger's hash chain. Entries written before chaining are counted + * as `unchained` while they lead the file; after the first chained entry every + * line must carry the hash of its predecessor. `head` is the hash of the last + * line; `expectHead` compares it with a value recorded elsewhere, which also + * detects a truncated tail. + */ +export function verifyLedger({ logPath = actionLogPath(), expectHead } = {}) { + if (!fs.existsSync(logPath)) { + const ok = !expectHead; + return { ok, entries: 0, chained: 0, unchained: 0, breaks: [], head: null, ...(expectHead ? { headMatches: false } : {}) }; + } + const lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(Boolean); + const breaks = []; + let chained = 0; + let unchained = 0; + let chainStarted = false; + for (const [index, line] of lines.entries()) { + let entry; + try { entry = JSON.parse(line); } catch { + breaks.push({ line: index + 1, reason: 'malformed JSON' }); + continue; + } + if (entry.prevHash === undefined) { + if (chainStarted) breaks.push({ line: index + 1, id: entry.id, reason: 'entry without prevHash after the chain started' }); + else unchained += 1; + continue; + } + chainStarted = true; + chained += 1; + const expected = index === 0 ? null : lineHash(lines[index - 1]); + if (entry.prevHash !== expected) { + breaks.push({ line: index + 1, id: entry.id, reason: index === 0 ? 'first entry names a predecessor' : 'previous entry changed, removed or reordered', expected, actual: entry.prevHash }); + } + } + const head = lines.length ? lineHash(lines[lines.length - 1]) : null; + const report = { ok: breaks.length === 0, entries: lines.length, chained, unchained, breaks, head }; + if (expectHead) { + report.headMatches = head === expectHead || (head !== null && expectHead.length >= 8 && head.startsWith(expectHead)); + if (!report.headMatches) report.ok = false; + } + return report; +} + export function recordAction(entry, { logPath = actionLogPath() } = {}) { if (process.env.ATS_ACTION_DISABLE === '1') return null; const record = buildRecord(entry); diff --git a/packages/core/index.js b/packages/core/index.js index 8ad9109..aee7264 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -87,7 +87,7 @@ export { evaluateTaskHierarchy, contextForTask, } from './task-context.js'; -export { actionLogPath, resolveActor, recordAction, listActions, snapshotTask, taskHistory, findAction, mostRecentUndoable, revertAction } from './action-ledger.js'; +export { actionLogPath, resolveActor, verifyLedger, recordAction, listActions, snapshotTask, taskHistory, findAction, mostRecentUndoable, revertAction } from './action-ledger.js'; export { TASK_EVENT_STATE_VERSION, TASK_EVENT_SPOOL_VERSION, diff --git a/packages/core/test/ledger-chain.test.js b/packages/core/test/ledger-chain.test.js new file mode 100644 index 0000000..56738d6 --- /dev/null +++ b/packages/core/test/ledger-chain.test.js @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { recordAction, verifyLedger } from '../action-ledger.js'; + +const sha = (line) => createHash('sha256').update(line, 'utf8').digest('hex'); + +function ledger(fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ats-ledger-chain-')); + const logPath = path.join(dir, 'action-log.jsonl'); + try { return fn(logPath); } finally { fs.rmSync(dir, { recursive: true, force: true }); } +} + +const write = (logPath, n) => { + for (let i = 0; i < n; i += 1) { + recordAction({ agent: 'a', action: `step.${i}`, task: { projectId: 'p', taskId: 't' }, before: { content: 'x'.repeat(i * 40000) } }, { logPath }); + } +}; + +test('each record names the hash of the line before it, and verify accepts the chain', () => ledger((logPath) => { + write(logPath, 4); + const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n'); + assert.equal(JSON.parse(lines[0]).prevHash, null); + for (let i = 1; i < lines.length; i += 1) assert.equal(JSON.parse(lines[i]).prevHash, sha(lines[i - 1])); + const report = verifyLedger({ logPath }); + assert.equal(report.ok, true); + assert.equal(report.chained, 4); + assert.equal(report.head, sha(lines[3])); + assert.equal(verifyLedger({ logPath, expectHead: report.head.slice(0, 12) }).ok, true); +})); + +test('verify reports an edited, removed or truncated entry', () => ledger((logPath) => { + write(logPath, 4); + const original = fs.readFileSync(logPath, 'utf8'); + const head = verifyLedger({ logPath }).head; + const lines = original.trim().split('\n'); + + const edited = [...lines]; + edited[1] = edited[1].replace('step.1', 'step.X'); + fs.writeFileSync(logPath, edited.join('\n') + '\n'); + const e = verifyLedger({ logPath }); + assert.equal(e.ok, false); + assert.deepEqual(e.breaks.map((b) => b.line), [3]); + + fs.writeFileSync(logPath, [lines[0], lines[2], lines[3]].join('\n') + '\n'); + assert.deepEqual(verifyLedger({ logPath }).breaks.map((b) => b.line), [2]); + + fs.writeFileSync(logPath, lines.slice(0, 3).join('\n') + '\n'); + assert.equal(verifyLedger({ logPath }).ok, true); + const truncated = verifyLedger({ logPath, expectHead: head }); + assert.equal(truncated.ok, false); + assert.equal(truncated.headMatches, false); +})); + +test('legacy unchained entries lead the file; the chain continues from them', () => ledger((logPath) => { + const legacy = [{ id: 'old-1', ts: '2026-01-01T00:00:00Z', agent: 'a', action: 'x' }, { id: 'old-2', ts: '2026-01-02T00:00:00Z', agent: 'a', action: 'y' }]; + fs.writeFileSync(logPath, legacy.map((e) => JSON.stringify(e)).join('\n') + '\n'); + write(logPath, 2); + const report = verifyLedger({ logPath }); + assert.equal(report.ok, true); + assert.equal(report.unchained, 2); + assert.equal(report.chained, 2); + fs.appendFileSync(logPath, JSON.stringify({ id: 'late', action: 'z' }) + '\n'); + assert.match(verifyLedger({ logPath }).breaks[0].reason, /without prevHash/); +})); From d3be46fd6f339bae4fb4ac5cafd3c7335b8505bf Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 8 Oct 2026 03:10:50 +0000 Subject: [PATCH 09/11] feat(kg): carry a ratification tier on fact proposals kg propose --tier source-fact|action-record|statement|belief (and a tier field per batch line) records what kind of claim a proposal makes. kg pending shows each proposal's tier, counts per tier and filters with --tier; ratified facts keep the tier in their provenance and exports. --- packages/cli/bin/ats.js | 5 ++-- packages/cli/parser.js | 8 +++--- packages/cli/test/kg-governance.test.js | 23 ++++++++++++++++ packages/core/kg-store.js | 36 +++++++++++++++++++++---- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/cli/bin/ats.js b/packages/cli/bin/ats.js index dfbdf62..0ef1c79 100755 --- a/packages/cli/bin/ats.js +++ b/packages/cli/bin/ats.js @@ -1199,7 +1199,7 @@ async function handleKg() { const report = proposeFactLines(text.split('\n'), { by: agentId, requireSource: args.options['require-source'], - defaults: { domain: args.options.domain, source: args.options.source, confidence: args.options.confidence, validAt: args.options['valid-at'], learnedAt: args.options['learned-at'] }, + defaults: { domain: args.options.domain, source: args.options.source, confidence: args.options.confidence, validAt: args.options['valid-at'], learnedAt: args.options['learned-at'], tier: args.options.tier }, }); const ok = report.refused === 0 && report.invalid === 0; report.message = ok @@ -1235,6 +1235,7 @@ async function handleKg() { additive: !!args.options.additive, acknowledgeRejected: args.options['acknowledge-rejected'], requireSource: args.options['require-source'], + tier: args.options.tier, }); } catch (err) { if (err instanceof KgGateError) return kgGateOutcome(err); @@ -1389,7 +1390,7 @@ async function handleKg() { return factHistory(args.positional[0]); } case 'pending': - return pendingFactProposals({ domain: args.options.domain }); + return pendingFactProposals({ domain: args.options.domain, tier: args.options.tier }); case 'stats': return kgStats({ listReviewItems }); case 'export': { diff --git a/packages/cli/parser.js b/packages/cli/parser.js index 77563d8..4ce830c 100644 --- a/packages/cli/parser.js +++ b/packages/cli/parser.js @@ -30,7 +30,7 @@ const VALUE_OPTIONS = new Set([ 'format', 'content', 'append', 'prepend', 'title', 'project', 'projects', 'limit', 'budget-ms', 'timeout-ms', 'input', 'output', 'file', 'out', 'journal', 'if-match', 'idempotency-key', 'due', 'priority', 'tags', 'reminder', - 'domain', 'source', 'subject', 'predicate', 'confidence', 'task', 'by', 'agent', 'actor-kind', 'session', 'note', 'require-source', 'expect-head', + 'domain', 'source', 'subject', 'predicate', 'confidence', 'task', 'by', 'agent', 'actor-kind', 'session', 'note', 'require-source', 'expect-head', 'tier', 'reason', 'url', 'type', 'desc', 'display', 'extract', 'folder', 'from', 'to', 'days', 'since', 'state', 'spool', 'interval', 'due-within-hours', 'max', 'threshold', 'max-corpus', 'rerank-depth', 'min-sources', 'facts-limit', @@ -808,6 +808,7 @@ Usage: ats kg propose SUBJ PRED OBJ [--domain D --source REF --confidence C --task P/T] [--supersedes FACT_ID | --additive] [--acknowledge-rejected ID] [--valid-at ISO --learned-at ISO] [--require-source any|checkable] + [--tier source-fact|action-record|statement|belief] ats kg propose --file FILE|- One JSON object per line, every line through the gate; --domain/--source/ --confidence fill what a line lacks @@ -821,8 +822,9 @@ Usage: URLs with --network; exit 2 when stale or changed; --propose-retract stages retractions ats kg retract FACT_ID [--reason "..."] Retraction proposal — reviewed too - ats kg pending [--domain D] What each queued proposal would do, - checked against the store now + ats kg pending [--domain D --tier T] What each queued proposal would do, + checked against the store now, with + its claimed tier and a count per tier ats kg ratify Write APPROVED proposals to the store ats kg ask "QUESTION" [--domain D --limit N --include-retracted] [--as-of DATE] [--center ENTITY] [--semantic | --lexical] diff --git a/packages/cli/test/kg-governance.test.js b/packages/cli/test/kg-governance.test.js index 4f915df..b303460 100644 --- a/packages/cli/test/kg-governance.test.js +++ b/packages/cli/test/kg-governance.test.js @@ -173,3 +173,26 @@ test('ledger verify accepts the chained ledger and exits 2 once an entry was edi const head = runProcess(['ledger', 'verify', '--expect-head', ok.head], opts); assert.equal(head.status, 2); }); + +test('proposals carry a ratification tier into the pending view, the fact provenance and the export', () => { + const opts = { store: 'tier' }; + const record = run(['kg', 'propose', 'Acme', 'signed', 'renewal', '--source', 'task://p1/t1', '--tier', 'action-record'], opts); + run(['kg', 'propose', 'Acme', 'likes', 'quarterly reviews', '--source', 'task://p1/t1', '--tier', 'belief'], opts); + run(['kg', 'propose', 'Acme', 'uses', 'the old portal', '--source', 'task://p1/t1'], opts); + const bad = runProcess(['kg', 'propose', 'Acme', 'is', 'big', '--tier', 'rumor'], opts); + assert.notEqual(bad.status, 0); + assert.match(bad.stdout + bad.stderr, /tier must be one of/); + + const pending = run(['kg', 'pending'], opts); + assert.deepEqual(pending.byTier, { 'action-record': 1, belief: 1, unclassified: 1 }); + const beliefs = run(['kg', 'pending', '--tier', 'belief'], opts); + assert.equal(beliefs.count, 1); + assert.equal(beliefs.pending[0].predicate, 'likes'); + + const factId = approveAndRatify(record.reviewId, opts); + const facts = run(['kg', 'facts'], opts); + assert.equal((facts.facts || facts).find((f) => f.id === factId).provenance.tier, 'action-record'); + const exported = runProcess(['kg', 'export', '--cypher', '--dialect', 'neo4j'], opts); + assert.equal(exported.status, 0, exported.stderr); + assert.match(exported.stdout, /tier/); +}); diff --git a/packages/core/kg-store.js b/packages/core/kg-store.js index 548a3f8..4c0f2dc 100644 --- a/packages/core/kg-store.js +++ b/packages/core/kg-store.js @@ -167,6 +167,20 @@ export function classifySource(fact = {}) { const CHECKABLE_SOURCES = new Set(['task', 'file', 'url']); +/** + * Ratification tiers a proposer can claim: `source-fact` (read from a system of + * record), `action-record` (records an action that happened), `statement` (a + * person said it), `belief` (an inference that needs judgment). + */ +export const FACT_TIERS = ['source-fact', 'action-record', 'statement', 'belief']; + +function factTier(value) { + if (value === undefined || value === null || value === '') return undefined; + const tier = String(value).trim().toLowerCase(); + if (!FACT_TIERS.includes(tier)) throw new Error(`kg: tier must be one of ${FACT_TIERS.join(', ')}, got "${value}".`); + return tier; +} + /** * Provenance policy for proposals: `any` needs a non-empty source, `checkable` * a task, file or URL reference that `kg verify` can recheck. Read from @@ -269,7 +283,8 @@ export function checkFactProposal(proposal, { facts = [], reviewItems = [], supe * ratification; `additive` allows a second value for the same * subject+predicate; `acknowledgeRejected` re-opens a triple a human declined. */ -export function proposeFact({ subject, predicate, object, domain, source, confidence, taskRef, by, supersedes, additive, acknowledgeRejected, validAt, learnedAt, requireSource } = {}, { queuePath, factsPath } = {}) { +export function proposeFact({ subject, predicate, object, domain, source, confidence, taskRef, by, supersedes, additive, acknowledgeRejected, validAt, learnedAt, requireSource, tier } = {}, { queuePath, factsPath } = {}) { + const claimedTier = factTier(tier); const payload = { op: 'add', ...(validAt !== undefined ? { validAt: factTimestamp(validAt, 'validAt') } : {}), @@ -281,6 +296,7 @@ export function proposeFact({ subject, predicate, object, domain, source, confid source: source || null, confidence: confidence || 'medium', ...(taskRef ? { taskRef } : {}), + ...(claimedTier ? { tier: claimedTier } : {}), }; const { facts } = loadFacts(factsPath ? { factsPath } : {}); if (supersedes) { @@ -349,6 +365,7 @@ export function proposeFactLines(lines, { by, defaults = {}, requireSource, queu additive: !!input.additive, acknowledgeRejected: input.acknowledgeRejected, requireSource, + tier: input.tier ?? defaults.tier, }, paths); counts.staged += 1; results.push({ line, ok: true, reviewId: item.id, ...(item.payload.supersedes ? { supersedes: item.payload.supersedes } : {}) }); @@ -459,7 +476,7 @@ export function ratifyFactItem(item, { factsPath = kgFactsPath(), now = new Date tLearned: p.learnedAt ? factTimestamp(p.learnedAt, 'learnedAt', now) : item.stagedAt && item.stagedAt <= at ? item.stagedAt : at, confidence: p.confidence || 'medium', ...(p.taskRef ? { taskRef: p.taskRef } : {}), ...(p.supersedes ? { supersedes: p.supersedes } : {}), - provenance: { proposedBy: item.stagedBy || null, source: p.source || null, proposalId: item.id, ratifiedBy: item.decidedBy || null, ratifiedAt: at }, + provenance: { proposedBy: item.stagedBy || null, source: p.source || null, ...(p.tier ? { tier: p.tier } : {}), proposalId: item.id, ratifiedBy: item.decidedBy || null, ratifiedAt: at }, }; if (p.supersedes) { const old = mustBeOpen(p.supersedes, 'supersede'); @@ -830,7 +847,8 @@ export function factsForTask({ projectId, taskId, query, domain, limit = 5, fact * supersedes is already closed). Grouped by domain so a reviewer sees what a * `kg ratify --all` would promote into each graph. */ -export function pendingFactProposals({ domain, factsPath, queuePath } = {}) { +export function pendingFactProposals({ domain, tier, factsPath, queuePath } = {}) { + const tierFilter = factTier(tier); const { facts } = loadFacts(factsPath ? { factsPath } : {}); const items = listReviewItems({ kind: 'kg.fact', ...(queuePath ? { queuePath } : {}) }); const open = items.filter((i) => i.status === 'pending' || i.status === 'approved'); @@ -847,6 +865,7 @@ export function pendingFactProposals({ domain, factsPath, queuePath } = {}) { ...(item.decidedBy ? { approvedBy: item.decidedBy, approvedAt: item.decidedAt || null } : {}), }; if (p.op === 'retract' || p.op === 'confirm') { + if (tierFilter) continue; const target = facts.find((f) => f.id === p.factId); if (domain && target && target.domain !== domain) continue; const stale = !target ? 'no such fact' : target.status !== 'active' ? `already ${target.status} since ${target.tInvalid}` : null; @@ -864,6 +883,7 @@ export function pendingFactProposals({ domain, factsPath, queuePath } = {}) { } const factDomain = p.domain || 'default'; if (domain && factDomain !== domain) continue; + if (tierFilter && p.tier !== tierFilter) continue; const entry = { ...base, domain: factDomain, @@ -871,6 +891,7 @@ export function pendingFactProposals({ domain, factsPath, queuePath } = {}) { predicate: p.predicate, object: p.object, source: p.source || null, + tier: p.tier || 'unclassified', confidence: p.confidence || 'medium', ...(p.taskRef ? { taskRef: p.taskRef } : {}), ...(p.supersedes ? { supersedes: p.supersedes } : {}), @@ -898,8 +919,12 @@ export function pendingFactProposals({ domain, factsPath, queuePath } = {}) { } pending.sort((a, b) => String(a.domain).localeCompare(String(b.domain)) || String(a.stagedAt).localeCompare(String(b.stagedAt))); const byDomain = {}; - for (const entry of pending) byDomain[entry.domain] = (byDomain[entry.domain] || 0) + 1; - return { count: pending.length, byDomain, pending }; + const byTier = {}; + for (const entry of pending) { + byDomain[entry.domain] = (byDomain[entry.domain] || 0) + 1; + if (entry.tier) byTier[entry.tier] = (byTier[entry.tier] || 0) + 1; + } + return { count: pending.length, byDomain, byTier, pending }; } export function kgStats({ factsPath, listReviewItems } = {}) { @@ -940,6 +965,7 @@ const CYPHER_FACT_PROPS = [ ['tInvalid', (f) => f.tInvalid], ['confidence', (f) => f.confidence], ['source', (f) => f.provenance?.source], + ['tier', (f) => f.provenance?.tier], ['proposedBy', (f) => f.provenance?.proposedBy], ['proposalId', (f) => f.provenance?.proposalId], ['ratifiedBy', (f) => f.provenance?.ratifiedBy], From e84bb3b27970a8504b45a90452d4d1cc2efd6636 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 8 Oct 2026 03:11:11 +0000 Subject: [PATCH 10/11] build(deps): resolve proxy-addr 2.0.8 in the lockfile Refresh the transitive Express dependency to the patched release; npm audit reports no findings. --- package-lock.json | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 02580db..8a133d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1723,16 +1723,19 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/punycode": { From cf050d3fce3b54430863d3c8e6c4f6fea046116b Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 8 Oct 2026 03:12:02 +0000 Subject: [PATCH 11/11] docs: document the 0.18.0 release candidate Changelog entries, README examples and reliability notes for complete completed listings, actors, ledger verification, fact source verification, provenance policy, review separation, ratification tiers and lossless normalization. --- CHANGELOG.md | 10 ++++++++++ README.md | 7 ++++++- docs/cli-reliability.md | 31 +++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81cc514..002d747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ ## Unreleased +- **Complete completed-task listings.** `ats tasks completed [DAYS]` pages through the completion window past the backend's per-call limit, deduplicates boundary entries, and reports `pages` and `complete`; a listing that stops at its page budget warns and fails `--require-complete`. +- **Actor on every write.** Ledger records carry `actor: { id, kind, session? }` for agents, humans and unattributed callers. A global `--agent` names the acting agent for one invocation; `ATS_ACTOR_KIND` and `ATS_SESSION_ID` are honored, and `ats ledger list` filters by `--actor-kind` and `--session`. +- **Fact source verification.** `ats kg verify` rechecks every active fact's source against the system that holds it — task references through the adapter, files on disk, URLs with `--network` — and reports `verified`, `changed`, `stale` or `unverifiable`. Stale or changed sources exit 2; `--propose-retract` stages reviewed retractions. +- **Lossless body normalization.** The Goal+Log normalizer keeps every word it receives; a body it cannot restructure without loss is written verbatim with a warning naming the words, and one-line bodies of literal `\n` sequences get a hint to pass real line breaks. +- **Review separation of duties.** Approvals come from an identity other than the one that staged the item (exit 4 otherwise); `ATS_REVIEW_REQUIRE_HUMAN=1` also requires a human approver. Items record the staging and deciding actor, and `review approve|reject` accept `--note`. +- **Provenance policy for facts.** `kg propose --require-source any|checkable`, or `ATS_KG_REQUIRE_SOURCE` with optional `ATS_KG_REQUIRE_SOURCE_DOMAINS`, refuses unsourced proposals with verdict `unsourced` and exit 4, per line in batches too. +- **Lossless frontmatter updates.** Obsidian and OKF updates rewrite only the keys they change; block lists, nested maps, comments, key case and unknown keys stay byte for byte. +- **Hash-chained action ledger.** Each record carries `prevHash`; `ats ledger verify` checks the chain, names breaks by line, exits 2, and prints a `head` hash that `--expect-head` compares. +- **Ratification tiers.** `kg propose --tier source-fact|action-record|statement|belief` records the kind of claim; `kg pending` counts and filters by tier, and ratified facts and exports keep it. +- **Dependency maintenance.** The lockfile resolves the patched proxy-addr release; `npm audit` reports no findings. - **Release preparation.** Document batching pending consumer changes before review and merging repository-only maintenance without publishing unchanged packages. - **Beads worker setup.** Document matching ATS and Beads actor identities for claiming and completing owned issues. - **Cache refresh validation.** Wait for the refreshed corpus and lease cleanup together in the stale-cache CLI proof, preserving the bounded deadline and final cleanup assertion. diff --git a/README.md b/README.md index 38179c0..5941840 100644 --- a/README.md +++ b/README.md @@ -244,8 +244,10 @@ ats history --restore --dry-run ats kg propose "Acme GmbH" "prefers" "invoices as PDF" --domain sales --source "call 2026-08-01" ats kg propose "Acme GmbH" "prefers" "invoices as XML" --domain sales --supersedes # replaces, in one ratification ats kg propose --file facts.jsonl --domain sales # one JSON object per line, every line through the gate +ats kg propose "Acme GmbH" "signed" "renewal" --source task:/// --tier action-record ats kg pending # what each queued proposal would do to the graph -ats review approve && ats kg ratify --all +ats review approve && ats kg ratify --all # approval comes from someone other than the proposer +ats kg verify # recheck every fact's source; exit 2 when one is gone or changed ats kg ask "what does Acme prefer" --domain sales --json # + confidence.verdict ats kg ask "what does Acme prefer" --domain sales --as-of 2026-06-30 # what the store believed then ats kg ask "invoices" --center "Acme GmbH" --semantic # anchored on one entity, embedder-backed @@ -256,6 +258,8 @@ ats kg export --dialect falkordb # openCypher for FalkorDB / Neo4j, re ats kg export --graphiti > episodes.jsonl # Graphiti episodes with the provenance record ats kg export --cypher --include-retracted # closed facts too, with tInvalid and who closed them ats ledger record --action release.verified --advanced true +ats ledger list --actor-kind agent --session # who acted, agent or human, in which session +ats ledger verify # hash-chained ledger; prints head for --expect-head ats security set --trust trusted --allow-actions read --allow-resources task:self ats security check --action read --resource task:self --reason "load context" ats events watch --json # NDJSON observations; never launches agents @@ -267,6 +271,7 @@ ats batch changes.jsonl --journal run.jsonl # apply and resume by stable item ats state doctor # validate local schemas and file permissions ats state import bundle.json --force --dry-run ats auth status --non-interactive # bounded to 15s; override with --timeout-ms +ats tasks completed 30 --projects --require-complete # every completion in the window, paged ats review list # writes staged by approvalRequired targets ats review approve ID && ats review apply --all ats cache sync # refresh the corpus cache (find also refreshes a stale one in the background) diff --git a/docs/cli-reliability.md b/docs/cli-reliability.md index 649d2ba..2ff7fe5 100644 --- a/docs/cli-reliability.md +++ b/docs/cli-reliability.md @@ -41,3 +41,34 @@ Explicit source times must be ISO timestamps with a timezone, normalize to UTC a Freshness age uses the last reviewed confirmation, then learned/ratified time, then legacy validity time. Age is a review signal and never automatically closes a fact. Confirmation requires source evidence, approval and ratification, then appends `lastConfirmedAt` and confirmation provenance while retaining the original fact. Every ratification checks the approved payload digest and rechecks active facts under the same lock as its append. A conflicting proposal cannot silently become current because another fact was approved first. Repeated ratification of one proposal cannot append twice. The CLI durably claims review items and reports failed ratifications with exit 5. Legacy approvals without digests need a fresh proposal and approval. + +## Fact source verification and provenance policy + +```sh +ats kg propose "Acme" "renews" "in March" --source task://PROJECT/TASK --tier action-record +ats kg verify --domain sales --json +ats kg verify --network --propose-retract +ATS_KG_REQUIRE_SOURCE=checkable ATS_KG_REQUIRE_SOURCE_DOMAINS=sales ats kg propose ... +``` + +`kg verify` reads each active fact's source from the system that holds it: `task://PROJECT/TASK` and `--task` references through the active adapter, `file:` and relative or absolute paths on disk, and http(s) URLs when `--network` is given. Each fact is `verified`, `changed` (a file modified after the fact was learned or last confirmed), `stale` (the record is gone) or `unverifiable` (no checkable source, or a read error that says nothing about the record). Any stale or changed source exits 2. `--propose-retract` stages a reviewed retraction for every stale fact; nothing closes without approval. + +`--require-source any|checkable`, or `ATS_KG_REQUIRE_SOURCE` with an optional `ATS_KG_REQUIRE_SOURCE_DOMAINS` list, refuses proposals without a source (verdict `unsourced`, exit 4). `checkable` accepts the references `kg verify` can recheck. `--tier source-fact|action-record|statement|belief` records what kind of claim a proposal makes; `kg pending` counts and filters by tier, and ratified facts keep it in their provenance. + +## Review separation + +An approval comes from an identity other than the one that staged the item, compared by reviewer name and by acting agent; a matching decision exits 4. `ATS_REVIEW_REQUIRE_HUMAN=1` also refuses approvals from a process acting as an agent. Rejecting one's own proposal stays possible. Each item records `stagedActor` and `decidedActor`, and `--note` keeps the reason for a decision. + +## Actors and ledger integrity + +Every ledger record carries `actor: { id, kind, session? }`. `kind` is `agent` when `--agent NAME` or `ATS_AGENT_ID` names one, `human` for `ATS_ACTOR_KIND=human` or an interactive terminal, and `unattributed` otherwise; `ATS_SESSION_ID` binds the session. `ats ledger list --actor-kind` and `--session` filter on them. + +Each record also carries `prevHash`, the SHA-256 of the previous line. `ats ledger verify` checks the chain, names each break by line and exits 2. It prints `head`, the hash of the last entry; keep it elsewhere and pass `--expect-head HASH` to detect a truncated ledger. Entries written before chaining remain valid at the start of the file. + +## Complete listings and body normalization + +`ats tasks completed [DAYS]` pages through the completion window past the backend's per-call limit, deduplicates boundary entries and reports `pages` and `complete`; a listing that stops at its page budget carries a warning and fails `--require-complete`. + +The Goal+Log normalizer keeps every word of the body it receives. A body it cannot restructure without loss is written verbatim, and the CLI names the words that would have moved. A body sent as one line of literal `\n` sequences gets a warning to pass real line breaks. + +Obsidian and OKF updates rewrite only the frontmatter keys they change; block lists, nested maps, comments, key case and unknown keys stay byte for byte.