Skip to content

Commit acba7d7

Browse files
committed
Add agent session index handoff
1 parent ce34413 commit acba7d7

8 files changed

Lines changed: 281 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Andrej Karpathy's [LLM Wiki](https://www.mindstudio.ai/blog/andrej-karpathy-llm-
4747
- **Execution context.** `ats intent` captures outcome/why/done-when; `ats lifecycle` keeps stale context from steering current work; `ats security` scopes actions and audits allow/deny; `ats ledger` records what an agent did and whether the task advanced; `ats promote` turns exploration into a committed goal; `ats hierarchy evaluate` checks local work still supports its parent.
4848
- **Bounded events.** `ats events watch --json` emits deterministic `task.created/updated/completed/...` NDJSON, spooled `0600` with pending/ack recovery and stable dedup IDs. ATS only emits observations — a consumer still evaluates intent, validity, and security before acting.
4949
- **Task graph for agents.** Tasks become structured nodes with proof, writeback, review, lifecycle, and link edges instead of free-form memory text; see [`docs/task-graph-for-agents.md`](docs/task-graph-for-agents.md).
50+
- **Session-index handoff.** Coding-agent session browsers can keep raw transcript analytics while ATS stores the durable task-linked summary; see [`docs/agent-session-index.md`](docs/agent-session-index.md).
5051
- **Curated at write time.** Every item is hung on a "trunk" (a theme like `writing`, `client-work`) the moment it's captured, so retrieval has structure to grab.
5152

5253
Metadata lives in flat YAML frontmatter on the task body, typed links in `## Related`, consulted sources in `## References`. Writes are **add-only** — ATS never drops a row or link a human added. [`npm run prove:intent`](examples/intent-layer/) runs a deterministic synthetic proof of the full path.

docs/agent-session-index.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Agent session indexes
2+
3+
Session browsers and analytics tools are good at answering "what happened in
4+
this coding-agent run?" ATS answers a different question: "what durable context
5+
should future agents retrieve from the task system?"
6+
7+
The split is intentional:
8+
9+
- session-index tools own raw transcript search, token charts, timeline views,
10+
and per-run debugging
11+
- ATS owns durable task-linked memory, lifecycle validity, provenance, retrieval,
12+
and writeback
13+
14+
`@reneza/ats-core/session-index` provides the handoff schema between those layers.
15+
An agentsview-style tool can summarize a run and attach it to the task graph
16+
without asking ATS to become a transcript database.
17+
18+
```js
19+
import { normalizeSessionIndexEntry, sessionIndexTaskBody } from '@reneza/ats-core/session-index';
20+
21+
const session = normalizeSessionIndexEntry({
22+
id: 'codex-2026-07-04-trivy-gate',
23+
source: 'agentsview',
24+
title: 'Build Trivy gate for skillgate',
25+
repo: 'renezander030/skillgate',
26+
cwd: '/work/github-repos/skillgate',
27+
startedAt: '2026-07-04T08:00:00Z',
28+
endedAt: '2026-07-04T08:40:00Z',
29+
models: ['gpt-5'],
30+
tools: ['shell', 'apply_patch'],
31+
files: ['src/core.ts', 'src/spec.ts', 'test/core.test.ts'],
32+
tasks: [{ projectId: 'github-oss', taskId: 'skillgate-trivy', role: 'source' }],
33+
tokenStats: { input: 42000, output: 9000 },
34+
outcome: 'tests-pass',
35+
summary: 'Added a Trivy-backed gate for secrets, critical CVEs, and SBOM generation.',
36+
});
37+
38+
const body = sessionIndexTaskBody(session);
39+
```
40+
41+
The normalized entry is stable JSON: timestamps are ISO strings, arrays are
42+
deduplicated and sorted, token totals are explicit, and task references are kept
43+
as `{ projectId, taskId, role }` edges. Store the raw transcript wherever the
44+
session tool prefers; store the durable summary in ATS where retrieval can join
45+
it to work, decisions, blockers, and follow-up tasks.

packages/core/index.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export {
3131

3232
export { record as logUsage } from './usage-log.js';
3333

34+
export { SESSION_INDEX_VERSION, normalizeSessionIndexEntry, normalizeSessionIndex, sessionIndexTaskBody } from './session-index.js';
35+
export type { SessionTaskRef, SessionTokenStats, SessionIndexInput, SessionIndexEntry } from './session-index.js';
36+
3437
export { runConformance, formatConformance } from './conformance.js';
3538
export type {
3639
ConformanceStatus,

packages/core/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {
77
clear as clearCorpus,
88
} from './corpus-cache.js';
99
export { record as logUsage } from './usage-log.js';
10+
export { SESSION_INDEX_VERSION, normalizeSessionIndexEntry, normalizeSessionIndex, sessionIndexTaskBody } from './session-index.js';
1011
export { runConformance, formatConformance } from './conformance.js';
1112
export {
1213
TASK_CONTEXT_VERSION,

packages/core/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
"types": "./usage-log.d.ts",
3131
"default": "./usage-log.js"
3232
},
33+
"./session-index": {
34+
"types": "./session-index.d.ts",
35+
"default": "./session-index.js"
36+
},
3337
"./task-context": {
3438
"types": "./task-context.d.ts",
3539
"default": "./task-context.js"

packages/core/session-index.d.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
export const SESSION_INDEX_VERSION: 1;
2+
3+
export interface SessionTaskRef {
4+
projectId: string;
5+
taskId: string;
6+
role?: string;
7+
}
8+
9+
export interface SessionTokenStats {
10+
input: number;
11+
output: number;
12+
cacheRead: number;
13+
cacheWrite: number;
14+
total: number;
15+
}
16+
17+
export interface SessionIndexInput {
18+
id: string;
19+
source?: string;
20+
title?: string;
21+
cwd?: string;
22+
repo?: string;
23+
branch?: string;
24+
startedAt: string;
25+
endedAt?: string;
26+
models?: string[];
27+
tools?: string[];
28+
files?: string[];
29+
tasks?: SessionTaskRef[];
30+
tokenStats?: Partial<SessionTokenStats>;
31+
outcome?: string;
32+
summary?: string;
33+
}
34+
35+
export interface SessionIndexEntry {
36+
version: 1;
37+
id: string;
38+
source: string;
39+
title: string;
40+
cwd: string | null;
41+
repo: string | null;
42+
branch: string | null;
43+
startedAt: string;
44+
endedAt: string | null;
45+
models: string[];
46+
tools: string[];
47+
files: string[];
48+
taskRefs: Array<Required<SessionTaskRef>>;
49+
tokenStats: SessionTokenStats;
50+
outcome: string | null;
51+
summary: string | null;
52+
}
53+
54+
export function normalizeSessionIndexEntry(entry: SessionIndexInput): SessionIndexEntry;
55+
export function normalizeSessionIndex(entries: SessionIndexInput[]): SessionIndexEntry[];
56+
export function sessionIndexTaskBody(entry: SessionIndexInput): string;

packages/core/session-index.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
export const SESSION_INDEX_VERSION = 1;
2+
3+
const TOKEN_FIELDS = ['input', 'output', 'cacheRead', 'cacheWrite', 'total'];
4+
5+
function cleanString(value) {
6+
return typeof value === 'string' && value.trim() ? value.trim() : null;
7+
}
8+
9+
function stringList(value) {
10+
if (!Array.isArray(value)) return [];
11+
return [...new Set(value.map(cleanString).filter(Boolean))].sort();
12+
}
13+
14+
function tokenStats(value = {}) {
15+
const out = {};
16+
for (const field of TOKEN_FIELDS) {
17+
const n = Number(value[field] ?? 0);
18+
out[field] = Number.isFinite(n) && n >= 0 ? n : 0;
19+
}
20+
if (!out.total) {
21+
out.total = out.input + out.output + out.cacheRead + out.cacheWrite;
22+
}
23+
return out;
24+
}
25+
26+
function iso(value, fallback = null) {
27+
const text = cleanString(value);
28+
if (!text) return fallback;
29+
const d = new Date(text);
30+
return Number.isNaN(d.getTime()) ? fallback : d.toISOString();
31+
}
32+
33+
/**
34+
* Normalize an agent-session summary into the stable ATS handoff schema.
35+
* UI/indexing tools can own raw transcript search; ATS stores the durable
36+
* task-linked facts an agent should retrieve across sessions.
37+
*/
38+
export function normalizeSessionIndexEntry(entry) {
39+
if (!entry || typeof entry !== 'object') {
40+
throw new Error('session index entry must be an object');
41+
}
42+
const id = cleanString(entry.id);
43+
if (!id) throw new Error('session index entry requires id');
44+
const startedAt = iso(entry.startedAt);
45+
if (!startedAt) throw new Error('session index entry requires valid startedAt');
46+
47+
const tasks = Array.isArray(entry.tasks) ? entry.tasks : [];
48+
return {
49+
version: SESSION_INDEX_VERSION,
50+
id,
51+
source: cleanString(entry.source) || 'agent-session',
52+
title: cleanString(entry.title) || id,
53+
cwd: cleanString(entry.cwd),
54+
repo: cleanString(entry.repo),
55+
branch: cleanString(entry.branch),
56+
startedAt,
57+
endedAt: iso(entry.endedAt),
58+
models: stringList(entry.models),
59+
tools: stringList(entry.tools),
60+
files: stringList(entry.files),
61+
taskRefs: tasks
62+
.map((task) => ({
63+
projectId: cleanString(task?.projectId),
64+
taskId: cleanString(task?.taskId),
65+
role: cleanString(task?.role) || 'related',
66+
}))
67+
.filter((task) => task.projectId && task.taskId),
68+
tokenStats: tokenStats(entry.tokenStats),
69+
outcome: cleanString(entry.outcome),
70+
summary: cleanString(entry.summary),
71+
};
72+
}
73+
74+
export function normalizeSessionIndex(entries) {
75+
if (!Array.isArray(entries)) throw new Error('session index must be an array');
76+
return entries.map(normalizeSessionIndexEntry).sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.id.localeCompare(b.id));
77+
}
78+
79+
export function sessionIndexTaskBody(entry) {
80+
const s = normalizeSessionIndexEntry(entry);
81+
const lines = [
82+
`Agent session: ${s.title}`,
83+
'',
84+
`Source: ${s.source}`,
85+
`Started: ${s.startedAt}`,
86+
];
87+
if (s.endedAt) lines.push(`Ended: ${s.endedAt}`);
88+
if (s.repo) lines.push(`Repo: ${s.repo}`);
89+
if (s.cwd) lines.push(`CWD: ${s.cwd}`);
90+
if (s.branch) lines.push(`Branch: ${s.branch}`);
91+
if (s.models.length) lines.push(`Models: ${s.models.join(', ')}`);
92+
if (s.tools.length) lines.push(`Tools: ${s.tools.join(', ')}`);
93+
if (s.files.length) lines.push(`Files: ${s.files.join(', ')}`);
94+
lines.push(`Tokens: ${s.tokenStats.total}`);
95+
if (s.outcome) lines.push(`Outcome: ${s.outcome}`);
96+
if (s.summary) lines.push('', s.summary);
97+
return lines.join('\n');
98+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import {
4+
SESSION_INDEX_VERSION,
5+
normalizeSessionIndex,
6+
normalizeSessionIndexEntry,
7+
sessionIndexTaskBody,
8+
} from '../session-index.js';
9+
10+
test('normalizeSessionIndexEntry produces stable durable session summaries', () => {
11+
const entry = normalizeSessionIndexEntry({
12+
id: ' codex-123 ',
13+
source: 'agentsview',
14+
title: 'Build trivy gate',
15+
cwd: '/repo/skillgate',
16+
repo: 'renezander030/skillgate',
17+
branch: 'main',
18+
startedAt: '2026-07-04T10:00:00+02:00',
19+
endedAt: '2026-07-04T10:20:00+02:00',
20+
models: ['gpt-5', 'gpt-5', ''],
21+
tools: ['shell', 'apply_patch'],
22+
files: ['src/core.ts', 'src/core.ts', 'README.md'],
23+
tasks: [
24+
{ projectId: 'p1', taskId: 't1', role: 'source' },
25+
{ projectId: '', taskId: 'missing' },
26+
],
27+
tokenStats: { input: 100, output: 50 },
28+
outcome: 'merged',
29+
summary: 'Added a Trivy-backed finish-line gate.',
30+
});
31+
32+
assert.equal(entry.version, SESSION_INDEX_VERSION);
33+
assert.equal(entry.id, 'codex-123');
34+
assert.equal(entry.startedAt, '2026-07-04T08:00:00.000Z');
35+
assert.deepEqual(entry.models, ['gpt-5']);
36+
assert.deepEqual(entry.files, ['README.md', 'src/core.ts']);
37+
assert.deepEqual(entry.taskRefs, [{ projectId: 'p1', taskId: 't1', role: 'source' }]);
38+
assert.deepEqual(entry.tokenStats, { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, total: 150 });
39+
});
40+
41+
test('normalizeSessionIndex sorts sessions by start time then id', () => {
42+
const entries = normalizeSessionIndex([
43+
{ id: 'b', startedAt: '2026-07-04T10:00:00Z' },
44+
{ id: 'a', startedAt: '2026-07-04T10:00:00Z' },
45+
{ id: 'c', startedAt: '2026-07-04T09:00:00Z' },
46+
]);
47+
assert.deepEqual(entries.map((entry) => entry.id), ['c', 'a', 'b']);
48+
});
49+
50+
test('sessionIndexTaskBody renders a compact ATS note body', () => {
51+
const body = sessionIndexTaskBody({
52+
id: 'session-1',
53+
source: 'agentsview',
54+
title: 'Debug retrieval',
55+
startedAt: '2026-07-04T09:00:00Z',
56+
repo: 'renezander030/agentic-task-system',
57+
files: ['packages/core/retrieval.js'],
58+
tokenStats: { total: 1200 },
59+
summary: 'Found the stale ranking assumption.',
60+
});
61+
62+
assert.match(body, /Agent session: Debug retrieval/);
63+
assert.match(body, /Source: agentsview/);
64+
assert.match(body, /Repo: renezander030\/agentic-task-system/);
65+
assert.match(body, /Files: packages\/core\/retrieval\.js/);
66+
assert.match(body, /Tokens: 1200/);
67+
assert.match(body, /Found the stale ranking assumption/);
68+
});
69+
70+
test('normalizeSessionIndexEntry rejects missing id or invalid startedAt', () => {
71+
assert.throws(() => normalizeSessionIndexEntry({ startedAt: '2026-07-04T10:00:00Z' }), /requires id/);
72+
assert.throws(() => normalizeSessionIndexEntry({ id: 'x', startedAt: 'not-a-date' }), /valid startedAt/);
73+
});

0 commit comments

Comments
 (0)