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. 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": { 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 }); + } +}); 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..0ef1c79 100755 --- a/packages/cli/bin/ats.js +++ b/packages/cli/bin/ats.js @@ -70,6 +70,8 @@ import { evaluateTaskHierarchy, contextForTask, recordAction, + resolveActor, + verifyLedger, listActions, snapshotTask, taskHistory, @@ -84,6 +86,7 @@ import { snapshotTaskEvents, collectAndSpoolTaskEvents, normalizeTaskBody, + hasLiteralNewlineEscapes, contentHash, TRIAGE_TAG, syncCorpusCache, @@ -106,6 +109,8 @@ import { proposeRetract, proposeConfirm, staleFacts, + verifyFacts, + classifySource, ratifyFactItem, listKgFacts, askFacts, @@ -251,6 +256,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; @@ -977,6 +988,26 @@ 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'); +} + function tagsToArray(tags) { if (Array.isArray(tags)) return tags; if (typeof tags === 'string') return tags.split(',').map((s) => s.trim()).filter(Boolean); @@ -1004,6 +1035,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, @@ -1060,6 +1092,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 } : {}), }); @@ -1165,7 +1198,8 @@ async function handleKg() { const text = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8'); const report = proposeFactLines(text.split('\n'), { by: agentId, - defaults: { domain: args.options.domain, source: args.options.source, confidence: args.options.confidence, validAt: args.options['valid-at'], learnedAt: args.options['learned-at'] }, + 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'], tier: args.options.tier }, }); const ok = report.refused === 0 && report.invalid === 0; report.message = ok @@ -1200,6 +1234,8 @@ async function handleKg() { supersedes: args.options.supersedes, 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); @@ -1214,6 +1250,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.' }; @@ -1312,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': { @@ -1554,8 +1632,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': { @@ -1790,7 +1869,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 { @@ -1941,7 +2020,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 { @@ -2075,10 +2154,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'); } @@ -2420,6 +2505,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) || [], @@ -2428,11 +2514,21 @@ 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, 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 a939abe..4ce830c 100644 --- a/packages/cli/parser.js +++ b/packages/cli/parser.js @@ -24,13 +24,13 @@ 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', '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', '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', @@ -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 @@ -659,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) @@ -667,7 +669,16 @@ 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. + +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: @@ -772,10 +783,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.`; @@ -792,15 +807,24 @@ 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] + [--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 + 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 + --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 + 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] @@ -1101,7 +1125,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 +1197,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/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 new file mode 100644 index 0000000..b303460 --- /dev/null +++ b/packages/cli/test/kg-governance.test.js @@ -0,0 +1,198 @@ +/** + * `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', input } = {}) { + const dir = path.join(tempDir, store); + return spawnSync(process.execPath, [cli, ...argv, '--json'], { + encoding: 'utf8', + input, + 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)}`)); +}); + +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'); +}); + +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'); +}); + +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); +}); + +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/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/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' }], 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/action-ledger.d.ts b/packages/core/action-ledger.d.ts index efcf767..eb3a27a 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,8 @@ export interface ActionLedgerRecord extends ActionLedgerEntry { id: string; ts: string; agent: string; + actor: ActionActor; + prevHash?: string | null; sources: string[]; approvals: string[]; advanced: boolean; @@ -40,8 +49,19 @@ 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; 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..c7e985c 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 : [], @@ -64,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); @@ -89,7 +196,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 +240,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..aee7264 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'; @@ -84,7 +87,7 @@ export { evaluateTaskHierarchy, contextForTask, } from './task-context.js'; -export { actionLogPath, 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, @@ -107,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/kg-store.js b/packages/core/kg-store.js index 40678cb..4c0f2dc 100644 --- a/packages/core/kg-store.js +++ b/packages/core/kg-store.js @@ -149,6 +149,67 @@ 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']); + +/** + * 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 + * 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 +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 } = {}, { 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') } : {}), @@ -234,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) { @@ -243,6 +306,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 +333,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 +364,8 @@ export function proposeFactLines(lines, { by, defaults = {}, queuePath, factsPat supersedes: input.supersedes, 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 } : {}) }); @@ -409,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'); @@ -780,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'); @@ -797,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; @@ -814,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, @@ -821,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 } : {}), @@ -848,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 } = {}) { @@ -890,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], diff --git a/packages/core/kg-verify.js b/packages/core/kg-verify.js new file mode 100644 index 0000000..c2af3c0 --- /dev/null +++ b/packages/core/kg-verify.js @@ -0,0 +1,90 @@ +/** + * 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 { classifySource, listKgFacts } from './kg-store.js'; + +const NOT_FOUND = /not\s*found|no such|does not exist|404|410|deleted/i; + +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/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); 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/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 }); + } +}); diff --git a/packages/core/test/kg-verify.test.js b/packages/core/test/kg-verify.test.js new file mode 100644 index 0000000..dce7ad4 --- /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.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 }); + } +}); 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/); +})); 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); +});