Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,10 @@ ats history <project> <task> --restore <revision> --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 <fact-id> # 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://<project>/<task> --tier action-record
ats kg pending # what each queued proposal would do to the graph
ats review approve <id> && ats kg ratify --all
ats review approve <id> && 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
Expand All @@ -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 <project> <task> --action release.verified --advanced true
ats ledger list --actor-kind agent --session <id> # who acted, agent or human, in which session
ats ledger verify # hash-chained ledger; prints head for --expect-head
ats security set <project> <task> --trust trusted --allow-actions read --allow-resources task:self
ats security check <project> <task> --action read --resource task:self --reason "load context"
ats events watch --json # NDJSON observations; never launches agents
Expand All @@ -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 <id> --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)
Expand Down
31 changes: 31 additions & 0 deletions docs/cli-reliability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 7 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions packages/adapter-obsidian/test/obsidian.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
49 changes: 44 additions & 5 deletions packages/adapter-obsidian/vault.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
54 changes: 45 additions & 9 deletions packages/adapter-okf/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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);
}
19 changes: 19 additions & 0 deletions packages/adapter-okf/test/okf.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
Loading
Loading