diff --git a/packages/confluence/src/digest.test.ts b/packages/confluence/src/digest.test.ts new file mode 100644 index 00000000..3b8d3204 --- /dev/null +++ b/packages/confluence/src/digest.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { digest, type DigestContext } from './digest.js'; + +test('digest returns null for Confluence M1 no-op activity windows', async () => { + const ctx: DigestContext = { + provider: 'confluence', + window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, + async changeEvents() { + return [ + { + id: 'evt-1', + timestamp: '2026-05-12T08:00:00.000Z', + canonicalPath: '/confluence/pages/123__release-plan.json', + }, + ]; + }, + }; + + // Determinism: two runs over the same fixture produce byte-identical + // results (WI 2 acceptance criterion 4 in the workspace-primitives spec). + const first = await digest(ctx); + const second = await digest(ctx); + assert.deepEqual(second, first); + assert.equal(first, null); +}); diff --git a/packages/confluence/src/digest.ts b/packages/confluence/src/digest.ts new file mode 100644 index 00000000..b90fda9e --- /dev/null +++ b/packages/confluence/src/digest.ts @@ -0,0 +1,25 @@ +export interface DigestContext { + readonly provider: string; + readonly window: { + readonly from: string; + readonly to: string; + }; + changeEvents(filter?: { + providers?: string[]; + paths?: string[]; + }): Promise; +} + +export interface DigestBullet { + readonly text: string; + readonly canonicalPath: string; +} + +export interface DigestSection { + readonly provider: string; + readonly bullets: readonly DigestBullet[]; +} + +export type DigestHandler = (ctx: DigestContext) => Promise; + +export const digest: DigestHandler = async () => null; diff --git a/packages/confluence/src/index.ts b/packages/confluence/src/index.ts index e250a878..5f41111e 100644 --- a/packages/confluence/src/index.ts +++ b/packages/confluence/src/index.ts @@ -1,6 +1,7 @@ export { ConfluenceAdapter, } from './confluence-adapter.js'; +export * from './digest.js'; export type { ConnectionProvider, IngestError, @@ -53,6 +54,7 @@ export { confluenceLayoutPromptFile, CONFLUENCE_LAYOUT_PROMPT, } from './layout-prompt.js'; +export * from './layout.js'; export * from './summary.js'; export { diff --git a/packages/confluence/src/layout.test.ts b/packages/confluence/src/layout.test.ts new file mode 100644 index 00000000..4642d9a5 --- /dev/null +++ b/packages/confluence/src/layout.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { layoutManifest } from './layout.js'; + +const CANONICAL_ALIAS_SEGMENTS = new Set(['by-id', 'by-name', 'by-state', 'by-title']); + +test('layoutManifest exposes Confluence resources with canonical aliases and writeback schema pointers', () => { + const manifest = layoutManifest(); + + assert.equal(manifest.provider, 'confluence'); + assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-title', 'by-state']); + + for (const resource of manifest.resources) { + assert.ok(resource.path.startsWith('confluence/')); + assert.doesNotMatch(resource.path, /^\//u); + for (const alias of resource.aliasSegments) { + assert.ok(CANONICAL_ALIAS_SEGMENTS.has(alias), `unexpected alias segment ${alias}`); + } + for (const writeback of resource.writebackResources) { + assert.ok(writeback.path.startsWith('confluence/')); + assert.doesNotMatch(writeback.path, /^\//u); + assert.match(writeback.schemaId, /^confluence\/[a-z-]+$/u); + } + } +}); diff --git a/packages/confluence/src/layout.ts b/packages/confluence/src/layout.ts new file mode 100644 index 00000000..05483195 --- /dev/null +++ b/packages/confluence/src/layout.ts @@ -0,0 +1,47 @@ +export type MaterializationMode = 'eager' | 'lazy'; + +export interface WritebackResourceManifest { + readonly path: string; + readonly schemaId: string; +} + +export interface LayoutResourceManifest { + readonly path: string; + readonly title: string; + readonly materialization: MaterializationMode; + readonly aliasSegments: readonly string[]; + readonly writebackResources: readonly WritebackResourceManifest[]; +} + +export interface LayoutManifest { + readonly provider: string; + readonly filenameConvention: string; + readonly aliasSegments: readonly string[]; + readonly resources: readonly LayoutResourceManifest[]; +} + +export type LayoutManifestProvider = () => LayoutManifest; + +export const layoutManifest: LayoutManifestProvider = () => ({ + provider: 'confluence', + filenameConvention: '__.json', + aliasSegments: ['by-id', 'by-title', 'by-state'], + resources: [ + { + path: 'confluence/pages', + title: 'Pages', + materialization: 'eager', + aliasSegments: ['by-id', 'by-title', 'by-state'], + writebackResources: [ + { path: 'confluence/pages', schemaId: 'confluence/page' }, + ], + }, + { + path: 'confluence/spaces', + title: 'Spaces', + materialization: 'eager', + aliasSegments: ['by-id', 'by-title'], + writebackResources: [], + }, + ], +}); diff --git a/packages/github/src/digest.test.ts b/packages/github/src/digest.test.ts new file mode 100644 index 00000000..733edb41 --- /dev/null +++ b/packages/github/src/digest.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { digest, type DigestContext } from './digest.js'; + +test('digest returns deterministic GitHub bullets sorted by event time and id', async () => { + const ctx: DigestContext = { + provider: 'github', + window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, + async changeEvents(filter) { + assert.deepEqual(filter, { providers: ['github'] }); + return [ + { + id: 'evt-2', + timestamp: '2026-05-12T09:00:00.000Z', + action: 'closed', + canonicalPath: 'github/repos/acme/api/issues/43__remove-flake/meta.json', + }, + { + id: 'evt-1', + timestamp: '2026-05-12T08:00:00.000Z', + action: 'opened', + canonicalPath: '/github/repos/acme/api/issues/42__add-login/meta.json', + }, + ]; + }, + }; + + const first = await digest(ctx); + const second = await digest(ctx); + + assert.deepEqual(first, second); + assert.deepEqual(first, { + provider: 'github', + bullets: [ + { + text: '#42 was opened', + canonicalPath: 'github/repos/acme/api/issues/42__add-login/meta.json', + }, + { + text: '#43 was closed', + canonicalPath: 'github/repos/acme/api/issues/43__remove-flake/meta.json', + }, + ], + }); +}); + +test('digest classifies "reopened" as updated, not opened (word-boundary regex)', async () => { + const ctx: DigestContext = { + provider: 'github', + window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, + async changeEvents() { + return [ + { + id: 'evt-1', + timestamp: '2026-05-12T08:00:00.000Z', + action: 'reopened', + canonicalPath: 'github/repos/acme/api/issues/42__add-login/meta.json', + }, + ]; + }, + }; + + const result = await digest(ctx); + assert.deepEqual(result, { + provider: 'github', + bullets: [ + { + text: '#42 was updated', + canonicalPath: 'github/repos/acme/api/issues/42__add-login/meta.json', + }, + ], + }); +}); + +test('digest returns null for an empty GitHub event window', async () => { + const ctx: DigestContext = { + provider: 'github', + window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, + async changeEvents() { + return []; + }, + }; + + assert.equal(await digest(ctx), null); +}); diff --git a/packages/github/src/digest.ts b/packages/github/src/digest.ts new file mode 100644 index 00000000..a39f7535 --- /dev/null +++ b/packages/github/src/digest.ts @@ -0,0 +1,115 @@ +export interface DigestWindow { + readonly from: string; + readonly to: string; +} + +export interface DigestChangeEvent { + readonly id?: string; + readonly timestamp?: string; + readonly occurredAt?: string; + readonly eventType?: string; + readonly type?: string; + readonly action?: string; + readonly canonicalPath?: string; + readonly path?: string; +} + +export interface DigestContext { + readonly provider: string; + readonly window: DigestWindow; + changeEvents(filter?: { + providers?: string[]; + paths?: string[]; + }): Promise; +} + +export interface DigestBullet { + readonly text: string; + readonly canonicalPath: string; +} + +export interface DigestSection { + readonly provider: string; + readonly bullets: readonly DigestBullet[]; +} + +export type DigestHandler = (ctx: DigestContext) => Promise; + +export const digest: DigestHandler = async (ctx) => { + const events = await ctx.changeEvents({ providers: [ctx.provider] }); + const bullets = events + .filter(hasCanonicalPath) + .slice() + .sort(compareEvents) + .map((event) => { + const canonicalPath = normalizeDigestPath(event.canonicalPath); + return { + text: `${githubIdentifier(canonicalPath)} ${pastTense(event)}`, + canonicalPath, + }; + }); + + return bullets.length === 0 ? null : { provider: ctx.provider, bullets }; +}; + +function hasCanonicalPath(event: DigestChangeEvent): event is DigestChangeEvent & { canonicalPath: string } { + return ( + typeof event.canonicalPath === 'string' + && (event.canonicalPath === 'github' || event.canonicalPath.startsWith('github/') || event.canonicalPath.startsWith('/github/')) + ); +} + +function compareEvents(left: DigestChangeEvent, right: DigestChangeEvent): number { + // Compare parsed timestamps in ms rather than ISO strings: lexicographic + // string compare misorders events whose timestamps describe the same + // instant with different textual offsets (e.g. `Z` vs `+00:00`). + const leftMs = eventTimeMs(left); + const rightMs = eventTimeMs(right); + return ( + leftMs - rightMs + || (left.id ?? '').localeCompare(right.id ?? '') + || (left.canonicalPath ?? '').localeCompare(right.canonicalPath ?? '') + ); +} + +function eventTime(event: DigestChangeEvent): string { + return event.timestamp ?? event.occurredAt ?? ''; +} + +function eventTimeMs(event: DigestChangeEvent): number { + const raw = eventTime(event); + if (!raw) return Number.NEGATIVE_INFINITY; + const ms = Date.parse(raw); + return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms; +} + +function normalizeDigestPath(path: string): string { + return path.replace(/^\/+/u, ''); +} + +function githubIdentifier(path: string): string { + const segments = path.split('/').filter(Boolean); + const segment = segments.at(-1) === 'meta.json' || segments.at(-1) === 'metadata.json' + ? segments.at(-2) ?? path + : segments.at(-1) ?? path; + const basename = segment.replace(/\.[^.]+$/u, ''); + const separatorIndex = basename.lastIndexOf('__'); + return separatorIndex > 0 ? `#${basename.slice(0, separatorIndex)}` : basename; +} + +function pastTense(event: DigestChangeEvent): string { + const action = (event.action ?? event.eventType ?? event.type ?? '').toLowerCase(); + // Word boundaries (\b) prevent substring matches like "reopened" being + // misclassified as "opened" — a real GitHub webhook action that would + // otherwise produce a semantically wrong digest line. + if (/\b(open|opened|create|created|add|added|write|written)\b/u.test(action)) { + return 'was opened'; + } + if (/\b(close|closed|merge|merged)\b/u.test(action)) { + return 'was closed'; + } + if (/\b(delete|deleted|remove|removed)\b/u.test(action)) { + return 'was deleted'; + } + return 'was updated'; +} diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts index 92e8bb23..e1893626 100644 --- a/packages/github/src/index.ts +++ b/packages/github/src/index.ts @@ -20,7 +20,9 @@ import { createRouter } from './webhook/router.js'; import { GitHubWritebackHandler } from './writeback.js'; export * from './emit-auxiliary-files.js'; +export * from './digest.js'; export * from './index-emitter.js'; +export * from './layout.js'; export * from './layout-prompt.js'; export * from './summary.js'; export * from './thread.js'; diff --git a/packages/github/src/layout.test.ts b/packages/github/src/layout.test.ts new file mode 100644 index 00000000..b7cf2f42 --- /dev/null +++ b/packages/github/src/layout.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { layoutManifest } from './layout.js'; + +const CANONICAL_ALIAS_SEGMENTS = new Set(['by-id', 'by-name', 'by-state', 'by-title']); + +test('layoutManifest exposes GitHub resources with canonical aliases and writeback schema pointers', () => { + const manifest = layoutManifest(); + + assert.equal(manifest.provider, 'github'); + assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-name', 'by-title']); + assert.ok(manifest.resources.length > 0); + + for (const resource of manifest.resources) { + assert.ok(resource.path.startsWith('github/')); + assert.doesNotMatch(resource.path, /^\//u); + for (const alias of resource.aliasSegments) { + assert.ok(CANONICAL_ALIAS_SEGMENTS.has(alias), `unexpected alias segment ${alias}`); + } + for (const writeback of resource.writebackResources) { + assert.ok(writeback.path.startsWith('github/')); + assert.doesNotMatch(writeback.path, /^\//u); + assert.match(writeback.schemaId, /^github\/[a-z-]+$/u); + } + } +}); + +test('layoutManifest top-level aliasSegments contains the union of all resource alias segments', () => { + const manifest = layoutManifest(); + const declared = new Set(manifest.aliasSegments); + for (const resource of manifest.resources) { + for (const alias of resource.aliasSegments) { + assert.ok( + declared.has(alias), + `top-level aliasSegments missing ${alias} declared by resource ${resource.path}`, + ); + } + } +}); diff --git a/packages/github/src/layout.ts b/packages/github/src/layout.ts new file mode 100644 index 00000000..71bfa5d1 --- /dev/null +++ b/packages/github/src/layout.ts @@ -0,0 +1,60 @@ +export type MaterializationMode = 'eager' | 'lazy'; + +export interface WritebackResourceManifest { + readonly path: string; + readonly schemaId: string; +} + +export interface LayoutResourceManifest { + readonly path: string; + readonly title: string; + readonly materialization: MaterializationMode; + readonly aliasSegments: readonly string[]; + readonly writebackResources: readonly WritebackResourceManifest[]; +} + +export interface LayoutManifest { + readonly provider: string; + readonly filenameConvention: string; + readonly aliasSegments: readonly string[]; + readonly resources: readonly LayoutResourceManifest[]; +} + +export type LayoutManifestProvider = () => LayoutManifest; + +export const layoutManifest: LayoutManifestProvider = () => ({ + provider: 'github', + filenameConvention: '__/meta.json', + // Top-level aliasSegments is the union of every resource's alias segments + // so consumers that inspect only the manifest root can discover all + // lookup keys. `by-name` belongs here because `github/repos` exposes it. + aliasSegments: ['by-id', 'by-name', 'by-title'], + resources: [ + { + path: 'github/repos', + title: 'Repositories', + materialization: 'lazy', + aliasSegments: ['by-name'], + writebackResources: [], + }, + { + path: 'github/repos/*/*/issues', + title: 'Issues', + materialization: 'eager', + aliasSegments: ['by-id', 'by-title'], + writebackResources: [ + { path: 'github/repos/*/*/issues', schemaId: 'github/issue' }, + { path: 'github/repos/*/*/issues/comments', schemaId: 'github/issue-comment' }, + ], + }, + { + path: 'github/repos/*/*/pulls', + title: 'Pull requests', + materialization: 'eager', + aliasSegments: ['by-id', 'by-title'], + writebackResources: [ + { path: 'github/repos/*/*/pulls/reviews', schemaId: 'github/pull-request-review' }, + ], + }, + ], +}); diff --git a/packages/jira/src/digest.test.ts b/packages/jira/src/digest.test.ts new file mode 100644 index 00000000..b4e53b03 --- /dev/null +++ b/packages/jira/src/digest.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { digest, type DigestContext } from './digest.js'; + +test('digest returns null for Jira M1 no-op activity windows', async () => { + const ctx: DigestContext = { + provider: 'jira', + window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, + async changeEvents() { + return [ + { + id: 'evt-1', + timestamp: '2026-05-12T08:00:00.000Z', + canonicalPath: '/jira/issues/ENG-42.json', + }, + ]; + }, + }; + + // Determinism: two runs over the same fixture produce byte-identical + // results (WI 2 acceptance criterion 4 in the workspace-primitives spec). + const first = await digest(ctx); + const second = await digest(ctx); + assert.deepEqual(second, first); + assert.equal(first, null); +}); diff --git a/packages/jira/src/digest.ts b/packages/jira/src/digest.ts new file mode 100644 index 00000000..b90fda9e --- /dev/null +++ b/packages/jira/src/digest.ts @@ -0,0 +1,25 @@ +export interface DigestContext { + readonly provider: string; + readonly window: { + readonly from: string; + readonly to: string; + }; + changeEvents(filter?: { + providers?: string[]; + paths?: string[]; + }): Promise; +} + +export interface DigestBullet { + readonly text: string; + readonly canonicalPath: string; +} + +export interface DigestSection { + readonly provider: string; + readonly bullets: readonly DigestBullet[]; +} + +export type DigestHandler = (ctx: DigestContext) => Promise; + +export const digest: DigestHandler = async () => null; diff --git a/packages/jira/src/index.ts b/packages/jira/src/index.ts index 9073304c..24f6afa8 100644 --- a/packages/jira/src/index.ts +++ b/packages/jira/src/index.ts @@ -3,12 +3,14 @@ export { JiraAdapter, sanitizeJiraRecordForStorage, } from './jira-adapter.js'; +export * from './digest.js'; export * from './summary.js'; export * from './thread.js'; export { JIRA_LAYOUT_PROMPT, jiraLayoutPromptFile, } from './layout-prompt.js'; +export * from './layout.js'; export type { DeleteFileInput, FileSemantics, diff --git a/packages/jira/src/layout.test.ts b/packages/jira/src/layout.test.ts new file mode 100644 index 00000000..0c9abeec --- /dev/null +++ b/packages/jira/src/layout.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { layoutManifest } from './layout.js'; + +const CANONICAL_ALIAS_SEGMENTS = new Set(['by-id', 'by-name', 'by-state', 'by-title']); + +test('layoutManifest exposes Jira resources with canonical aliases and writeback schema pointers', () => { + const manifest = layoutManifest(); + + assert.equal(manifest.provider, 'jira'); + assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-title', 'by-state']); + + for (const resource of manifest.resources) { + assert.ok(resource.path.startsWith('jira/')); + assert.doesNotMatch(resource.path, /^\//u); + for (const alias of resource.aliasSegments) { + assert.ok(CANONICAL_ALIAS_SEGMENTS.has(alias), `unexpected alias segment ${alias}`); + } + for (const writeback of resource.writebackResources) { + assert.ok(writeback.path.startsWith('jira/')); + assert.doesNotMatch(writeback.path, /^\//u); + assert.match(writeback.schemaId, /^jira\/[a-z-]+$/u); + } + } +}); diff --git a/packages/jira/src/layout.ts b/packages/jira/src/layout.ts new file mode 100644 index 00000000..7139a39b --- /dev/null +++ b/packages/jira/src/layout.ts @@ -0,0 +1,56 @@ +export type MaterializationMode = 'eager' | 'lazy'; + +export interface WritebackResourceManifest { + readonly path: string; + readonly schemaId: string; +} + +export interface LayoutResourceManifest { + readonly path: string; + readonly title: string; + readonly materialization: MaterializationMode; + readonly aliasSegments: readonly string[]; + readonly writebackResources: readonly WritebackResourceManifest[]; +} + +export interface LayoutManifest { + readonly provider: string; + readonly filenameConvention: string; + readonly aliasSegments: readonly string[]; + readonly resources: readonly LayoutResourceManifest[]; +} + +export type LayoutManifestProvider = () => LayoutManifest; + +export const layoutManifest: LayoutManifestProvider = () => ({ + provider: 'jira', + filenameConvention: '__.json', + aliasSegments: ['by-id', 'by-title', 'by-state'], + resources: [ + { + path: 'jira/issues', + title: 'Issues', + materialization: 'eager', + aliasSegments: ['by-id', 'by-title', 'by-state'], + writebackResources: [ + { path: 'jira/issues', schemaId: 'jira/issue' }, + { path: 'jira/issues/comments', schemaId: 'jira/comment' }, + { path: 'jira/issues/transitions', schemaId: 'jira/transition' }, + ], + }, + { + path: 'jira/projects', + title: 'Projects', + materialization: 'eager', + aliasSegments: ['by-id', 'by-title'], + writebackResources: [], + }, + { + path: 'jira/sprints', + title: 'Sprints', + materialization: 'eager', + aliasSegments: ['by-id', 'by-title'], + writebackResources: [], + }, + ], +}); diff --git a/packages/linear/src/digest.test.ts b/packages/linear/src/digest.test.ts new file mode 100644 index 00000000..e653ab43 --- /dev/null +++ b/packages/linear/src/digest.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { digest, type DigestContext } from './digest.js'; + +test('digest returns deterministic Linear bullets sorted by event time and id', async () => { + const ctx: DigestContext = { + provider: 'linear', + window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, + async changeEvents(filter) { + assert.deepEqual(filter, { providers: ['linear'] }); + return [ + { + id: 'evt-2', + timestamp: '2026-05-12T09:00:00.000Z', + action: 'updated', + canonicalPath: 'linear/issues/AGE-17__lin_17.json', + }, + { + id: 'evt-1', + timestamp: '2026-05-12T08:00:00.000Z', + action: 'created', + canonicalPath: '/linear/issues/AGE-16__lin_16.json', + }, + ]; + }, + }; + + const first = await digest(ctx); + const second = await digest(ctx); + + assert.deepEqual(first, second); + assert.deepEqual(first, { + provider: 'linear', + bullets: [ + { + text: 'AGE-16 was created', + canonicalPath: 'linear/issues/AGE-16__lin_16.json', + }, + { + text: 'AGE-17 was updated', + canonicalPath: 'linear/issues/AGE-17__lin_17.json', + }, + ], + }); +}); + +test('digest returns null for an empty Linear event window', async () => { + const ctx: DigestContext = { + provider: 'linear', + window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, + async changeEvents() { + return []; + }, + }; + + assert.equal(await digest(ctx), null); +}); diff --git a/packages/linear/src/digest.ts b/packages/linear/src/digest.ts new file mode 100644 index 00000000..72fee523 --- /dev/null +++ b/packages/linear/src/digest.ts @@ -0,0 +1,109 @@ +export interface DigestWindow { + readonly from: string; + readonly to: string; +} + +export interface DigestChangeEvent { + readonly id?: string; + readonly timestamp?: string; + readonly occurredAt?: string; + readonly eventType?: string; + readonly type?: string; + readonly action?: string; + readonly canonicalPath?: string; + readonly path?: string; +} + +export interface DigestContext { + readonly provider: string; + readonly window: DigestWindow; + changeEvents(filter?: { + providers?: string[]; + paths?: string[]; + }): Promise; +} + +export interface DigestBullet { + readonly text: string; + readonly canonicalPath: string; +} + +export interface DigestSection { + readonly provider: string; + readonly bullets: readonly DigestBullet[]; +} + +export type DigestHandler = (ctx: DigestContext) => Promise; + +export const digest: DigestHandler = async (ctx) => { + const events = await ctx.changeEvents({ providers: [ctx.provider] }); + const bullets = events + .filter(hasCanonicalPath) + .slice() + .sort(compareEvents) + .map((event) => { + const canonicalPath = normalizeDigestPath(event.canonicalPath); + return { + text: `${linearIdentifier(canonicalPath)} ${pastTense(event)}`, + canonicalPath, + }; + }); + + return bullets.length === 0 ? null : { provider: ctx.provider, bullets }; +}; + +function hasCanonicalPath(event: DigestChangeEvent): event is DigestChangeEvent & { canonicalPath: string } { + return ( + typeof event.canonicalPath === 'string' + && (event.canonicalPath === 'linear' || event.canonicalPath.startsWith('linear/') || event.canonicalPath.startsWith('/linear/')) + ); +} + +function compareEvents(left: DigestChangeEvent, right: DigestChangeEvent): number { + // Compare parsed timestamps in ms rather than ISO strings: lexicographic + // string compare misorders events whose timestamps describe the same + // instant with different textual offsets (e.g. `Z` vs `+00:00`). + const leftMs = eventTimeMs(left); + const rightMs = eventTimeMs(right); + return ( + leftMs - rightMs + || (left.id ?? '').localeCompare(right.id ?? '') + || (left.canonicalPath ?? '').localeCompare(right.canonicalPath ?? '') + ); +} + +function eventTime(event: DigestChangeEvent): string { + return event.timestamp ?? event.occurredAt ?? ''; +} + +function eventTimeMs(event: DigestChangeEvent): number { + const raw = eventTime(event); + if (!raw) return Number.NEGATIVE_INFINITY; + const ms = Date.parse(raw); + return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms; +} + +function normalizeDigestPath(path: string): string { + return path.replace(/^\/+/u, ''); +} + +function linearIdentifier(path: string): string { + const segment = path.split('/').filter(Boolean).at(-1) ?? path; + const basename = segment.replace(/\.[^.]+$/u, ''); + const separatorIndex = basename.lastIndexOf('__'); + return separatorIndex > 0 ? basename.slice(0, separatorIndex) : basename; +} + +function pastTense(event: DigestChangeEvent): string { + const action = (event.action ?? event.eventType ?? event.type ?? '').toLowerCase(); + if (/(create|created|open|opened|add|added|write|written)/u.test(action)) { + return 'was created'; + } + if (/(delete|deleted|remove|removed)/u.test(action)) { + return 'was deleted'; + } + if (/(close|closed|resolve|resolved|cancel|canceled)/u.test(action)) { + return 'was closed'; + } + return 'was updated'; +} diff --git a/packages/linear/src/index.ts b/packages/linear/src/index.ts index 518a86d4..bc52e299 100644 --- a/packages/linear/src/index.ts +++ b/packages/linear/src/index.ts @@ -1,6 +1,8 @@ // Public barrel for runtime helpers, normalizers, and exported types. export * from './linear-adapter.js'; +export * from './digest.js'; export * from './index-emitter.js'; +export * from './layout.js'; export * from './layout-prompt.js'; export * from './path-mapper.js'; export * from './summary.js'; diff --git a/packages/linear/src/layout.test.ts b/packages/linear/src/layout.test.ts new file mode 100644 index 00000000..39d32f9e --- /dev/null +++ b/packages/linear/src/layout.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { layoutManifest } from './layout.js'; + +const CANONICAL_ALIAS_SEGMENTS = new Set(['by-id', 'by-name', 'by-state', 'by-title']); + +test('layoutManifest exposes Linear resources with canonical aliases and writeback schema pointers', () => { + const manifest = layoutManifest(); + + assert.equal(manifest.provider, 'linear'); + assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-name', 'by-title', 'by-state']); + assert.ok(manifest.resources.length > 0); + + for (const resource of manifest.resources) { + assert.ok(resource.path.startsWith('linear/')); + assert.doesNotMatch(resource.path, /^\//u); + for (const alias of resource.aliasSegments) { + assert.ok(CANONICAL_ALIAS_SEGMENTS.has(alias), `unexpected alias segment ${alias}`); + } + for (const writeback of resource.writebackResources) { + assert.ok(writeback.path.startsWith('linear/')); + assert.doesNotMatch(writeback.path, /^\//u); + assert.match(writeback.schemaId, /^linear\/[a-z-]+$/u); + } + } +}); + +test('layoutManifest top-level aliasSegments contains the union of all resource alias segments', () => { + const manifest = layoutManifest(); + const declared = new Set(manifest.aliasSegments); + for (const resource of manifest.resources) { + for (const alias of resource.aliasSegments) { + assert.ok( + declared.has(alias), + `top-level aliasSegments missing ${alias} declared by resource ${resource.path}`, + ); + } + } +}); diff --git a/packages/linear/src/layout.ts b/packages/linear/src/layout.ts new file mode 100644 index 00000000..35ef07fc --- /dev/null +++ b/packages/linear/src/layout.ts @@ -0,0 +1,58 @@ +export type MaterializationMode = 'eager' | 'lazy'; + +export interface WritebackResourceManifest { + readonly path: string; + readonly schemaId: string; +} + +export interface LayoutResourceManifest { + readonly path: string; + readonly title: string; + readonly materialization: MaterializationMode; + readonly aliasSegments: readonly string[]; + readonly writebackResources: readonly WritebackResourceManifest[]; +} + +export interface LayoutManifest { + readonly provider: string; + readonly filenameConvention: string; + readonly aliasSegments: readonly string[]; + readonly resources: readonly LayoutResourceManifest[]; +} + +export type LayoutManifestProvider = () => LayoutManifest; + +export const layoutManifest: LayoutManifestProvider = () => ({ + provider: 'linear', + filenameConvention: '__.json', + // Top-level aliasSegments is the union of every resource's alias segments + // so consumers that inspect only the manifest root can discover all + // lookup keys. `by-name` belongs here because `linear/teams` exposes it. + aliasSegments: ['by-id', 'by-name', 'by-title', 'by-state'], + resources: [ + { + path: 'linear/issues', + title: 'Issues', + materialization: 'eager', + aliasSegments: ['by-id', 'by-title', 'by-state'], + writebackResources: [ + { path: 'linear/issues', schemaId: 'linear/issue' }, + { path: 'linear/issues/comments', schemaId: 'linear/comment' }, + ], + }, + { + path: 'linear/projects', + title: 'Projects', + materialization: 'eager', + aliasSegments: ['by-id', 'by-title'], + writebackResources: [], + }, + { + path: 'linear/teams', + title: 'Teams', + materialization: 'eager', + aliasSegments: ['by-id', 'by-name'], + writebackResources: [], + }, + ], +}); diff --git a/packages/notion/src/digest.test.ts b/packages/notion/src/digest.test.ts new file mode 100644 index 00000000..b1efb751 --- /dev/null +++ b/packages/notion/src/digest.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { digest, type DigestContext } from './digest.js'; + +test('digest returns deterministic Notion bullets sorted by event time and id', async () => { + const ctx: DigestContext = { + provider: 'notion', + window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, + async changeEvents(filter) { + assert.deepEqual(filter, { providers: ['notion'] }); + return [ + { + id: 'evt-2', + timestamp: '2026-05-12T09:00:00.000Z', + action: 'updated', + canonicalPath: 'notion/pages/roadmap__page_b/page.md', + }, + { + id: 'evt-1', + timestamp: '2026-05-12T08:00:00.000Z', + action: 'created', + canonicalPath: '/notion/pages/launch-plan__page_a/page.md', + }, + ]; + }, + }; + + const first = await digest(ctx); + const second = await digest(ctx); + + assert.deepEqual(first, second); + assert.deepEqual(first, { + provider: 'notion', + bullets: [ + { + text: 'launch-plan was created', + canonicalPath: 'notion/pages/launch-plan__page_a/page.md', + }, + { + text: 'roadmap was updated', + canonicalPath: 'notion/pages/roadmap__page_b/page.md', + }, + ], + }); +}); + +test('digest classifies "unarchived" as updated, not archived (word-boundary regex)', async () => { + const ctx: DigestContext = { + provider: 'notion', + window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, + async changeEvents() { + return [ + { + id: 'evt-1', + timestamp: '2026-05-12T08:00:00.000Z', + action: 'unarchived', + canonicalPath: 'notion/pages/launch-plan__page_a/page.md', + }, + ]; + }, + }; + + const result = await digest(ctx); + assert.deepEqual(result, { + provider: 'notion', + bullets: [ + { + text: 'launch-plan was updated', + canonicalPath: 'notion/pages/launch-plan__page_a/page.md', + }, + ], + }); +}); + +test('digest accepts events with canonicalPath === "/notion" (root edge case)', async () => { + const ctx: DigestContext = { + provider: 'notion', + window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, + async changeEvents() { + return [ + { + id: 'evt-1', + timestamp: '2026-05-12T08:00:00.000Z', + action: 'updated', + canonicalPath: '/notion', + }, + ]; + }, + }; + const result = await digest(ctx); + assert.notEqual(result, null); + assert.equal(result?.bullets.length, 1); +}); + +test('digest returns null for an empty Notion event window', async () => { + const ctx: DigestContext = { + provider: 'notion', + window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, + async changeEvents() { + return []; + }, + }; + + assert.equal(await digest(ctx), null); +}); diff --git a/packages/notion/src/digest.ts b/packages/notion/src/digest.ts new file mode 100644 index 00000000..0f299906 --- /dev/null +++ b/packages/notion/src/digest.ts @@ -0,0 +1,117 @@ +export interface DigestWindow { + readonly from: string; + readonly to: string; +} + +export interface DigestChangeEvent { + readonly id?: string; + readonly timestamp?: string; + readonly occurredAt?: string; + readonly eventType?: string; + readonly type?: string; + readonly action?: string; + readonly canonicalPath?: string; + readonly path?: string; +} + +export interface DigestContext { + readonly provider: string; + readonly window: DigestWindow; + changeEvents(filter?: { + providers?: string[]; + paths?: string[]; + }): Promise; +} + +export interface DigestBullet { + readonly text: string; + readonly canonicalPath: string; +} + +export interface DigestSection { + readonly provider: string; + readonly bullets: readonly DigestBullet[]; +} + +export type DigestHandler = (ctx: DigestContext) => Promise; + +export const digest: DigestHandler = async (ctx) => { + const events = await ctx.changeEvents({ providers: [ctx.provider] }); + const bullets = events + .filter(hasCanonicalPath) + .slice() + .sort(compareEvents) + .map((event) => { + const canonicalPath = normalizeDigestPath(event.canonicalPath); + return { + text: `${notionIdentifier(canonicalPath)} ${pastTense(event)}`, + canonicalPath, + }; + }); + + return bullets.length === 0 ? null : { provider: ctx.provider, bullets }; +}; + +function hasCanonicalPath(event: DigestChangeEvent): event is DigestChangeEvent & { canonicalPath: string } { + return ( + typeof event.canonicalPath === 'string' + && ( + event.canonicalPath === 'notion' + || event.canonicalPath === '/notion' + || event.canonicalPath.startsWith('notion/') + || event.canonicalPath.startsWith('/notion/') + ) + ); +} + +function compareEvents(left: DigestChangeEvent, right: DigestChangeEvent): number { + // Compare parsed timestamps in ms rather than ISO strings: lexicographic + // string compare misorders events whose timestamps describe the same + // instant with different textual offsets (e.g. `Z` vs `+00:00`). + const leftMs = eventTimeMs(left); + const rightMs = eventTimeMs(right); + return ( + leftMs - rightMs + || (left.id ?? '').localeCompare(right.id ?? '') + || (left.canonicalPath ?? '').localeCompare(right.canonicalPath ?? '') + ); +} + +function eventTime(event: DigestChangeEvent): string { + return event.timestamp ?? event.occurredAt ?? ''; +} + +function eventTimeMs(event: DigestChangeEvent): number { + const raw = eventTime(event); + if (!raw) return Number.NEGATIVE_INFINITY; + const ms = Date.parse(raw); + return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms; +} + +function normalizeDigestPath(path: string): string { + return path.replace(/^\/+/u, ''); +} + +function notionIdentifier(path: string): string { + const segments = path.split('/').filter(Boolean); + const segment = segments.at(-1) === 'page.md' || segments.at(-1) === 'content.md' + ? segments.at(-2) ?? path + : segments.at(-1) ?? path; + const basename = segment.replace(/\.[^.]+$/u, ''); + const separatorIndex = basename.lastIndexOf('__'); + return separatorIndex > 0 ? basename.slice(0, separatorIndex) : basename; +} + +function pastTense(event: DigestChangeEvent): string { + const action = (event.action ?? event.eventType ?? event.type ?? '').toLowerCase(); + // Word boundaries (\b) prevent substring matches like "unarchived" being + // misclassified as "archived" — the semantic inverse, which would + // report a restored page as archived. + if (/\b(create|created|add|added|write|written)\b/u.test(action)) { + return 'was created'; + } + if (/\b(delete|deleted|remove|removed|archive|archived)\b/u.test(action)) { + return 'was archived'; + } + return 'was updated'; +} diff --git a/packages/notion/src/index.ts b/packages/notion/src/index.ts index b5dba42e..6fc93dd9 100644 --- a/packages/notion/src/index.ts +++ b/packages/notion/src/index.ts @@ -9,9 +9,11 @@ export * from './content/markdown.js'; export * from './content/renderer.js'; export * from './databases/ingestion.js'; export * from './databases/query.js'; +export * from './digest.js'; export * from './discovery/index.js'; export * from './emit-auxiliary-files.js'; export * from './index-emitter.js'; +export * from './layout.js'; export * from './layout-prompt.js'; export * from './pages/ingestion.js'; export * from './pages/properties.js'; diff --git a/packages/notion/src/layout.test.ts b/packages/notion/src/layout.test.ts new file mode 100644 index 00000000..58d259cf --- /dev/null +++ b/packages/notion/src/layout.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { layoutManifest } from './layout.js'; + +const CANONICAL_ALIAS_SEGMENTS = new Set(['by-id', 'by-name', 'by-state', 'by-title']); + +test('layoutManifest exposes Notion resources with canonical aliases and writeback schema pointers', () => { + const manifest = layoutManifest(); + + assert.equal(manifest.provider, 'notion'); + assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-title', 'by-name']); + assert.ok(manifest.resources.length > 0); + + for (const resource of manifest.resources) { + assert.ok(resource.path.startsWith('notion/')); + assert.doesNotMatch(resource.path, /^\//u); + for (const alias of resource.aliasSegments) { + assert.ok(CANONICAL_ALIAS_SEGMENTS.has(alias), `unexpected alias segment ${alias}`); + } + for (const writeback of resource.writebackResources) { + assert.ok(writeback.path.startsWith('notion/')); + assert.doesNotMatch(writeback.path, /^\//u); + assert.match(writeback.schemaId, /^notion\/[a-z-]+$/u); + } + } +}); diff --git a/packages/notion/src/layout.ts b/packages/notion/src/layout.ts new file mode 100644 index 00000000..511b646a --- /dev/null +++ b/packages/notion/src/layout.ts @@ -0,0 +1,57 @@ +export type MaterializationMode = 'eager' | 'lazy'; + +export interface WritebackResourceManifest { + readonly path: string; + readonly schemaId: string; +} + +export interface LayoutResourceManifest { + readonly path: string; + readonly title: string; + readonly materialization: MaterializationMode; + readonly aliasSegments: readonly string[]; + readonly writebackResources: readonly WritebackResourceManifest[]; +} + +export interface LayoutManifest { + readonly provider: string; + readonly filenameConvention: string; + readonly aliasSegments: readonly string[]; + readonly resources: readonly LayoutResourceManifest[]; +} + +export type LayoutManifestProvider = () => LayoutManifest; + +export const layoutManifest: LayoutManifestProvider = () => ({ + provider: 'notion', + filenameConvention: '__.json', + aliasSegments: ['by-id', 'by-title', 'by-name'], + resources: [ + { + path: 'notion/pages', + title: 'Pages', + materialization: 'eager', + aliasSegments: ['by-id', 'by-title'], + writebackResources: [ + { path: 'notion/pages', schemaId: 'notion/page' }, + { path: 'notion/pages/comments', schemaId: 'notion/comment' }, + ], + }, + { + path: 'notion/databases', + title: 'Databases', + materialization: 'eager', + aliasSegments: ['by-id', 'by-title'], + writebackResources: [ + { path: 'notion/databases', schemaId: 'notion/database' }, + ], + }, + { + path: 'notion/users', + title: 'Users', + materialization: 'eager', + aliasSegments: ['by-id', 'by-name'], + writebackResources: [], + }, + ], +}); diff --git a/packages/slack/src/digest.test.ts b/packages/slack/src/digest.test.ts new file mode 100644 index 00000000..d94704ae --- /dev/null +++ b/packages/slack/src/digest.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { digest, type DigestContext } from './digest.js'; + +test('digest returns null for Slack M1 no-op activity windows', async () => { + const ctx: DigestContext = { + provider: 'slack', + window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, + async changeEvents() { + return [ + { + id: 'evt-1', + timestamp: '2026-05-12T08:00:00.000Z', + canonicalPath: '/slack/channels/C123/messages/1747046400.000000.json', + }, + ]; + }, + }; + + // Determinism: two runs over the same fixture produce byte-identical + // results (WI 2 acceptance criterion 4 in the workspace-primitives spec). + const first = await digest(ctx); + const second = await digest(ctx); + assert.deepEqual(second, first); + assert.equal(first, null); +}); diff --git a/packages/slack/src/digest.ts b/packages/slack/src/digest.ts new file mode 100644 index 00000000..b90fda9e --- /dev/null +++ b/packages/slack/src/digest.ts @@ -0,0 +1,25 @@ +export interface DigestContext { + readonly provider: string; + readonly window: { + readonly from: string; + readonly to: string; + }; + changeEvents(filter?: { + providers?: string[]; + paths?: string[]; + }): Promise; +} + +export interface DigestBullet { + readonly text: string; + readonly canonicalPath: string; +} + +export interface DigestSection { + readonly provider: string; + readonly bullets: readonly DigestBullet[]; +} + +export type DigestHandler = (ctx: DigestContext) => Promise; + +export const digest: DigestHandler = async () => null; diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index 6a939cae..3c47be26 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -46,6 +46,7 @@ export { SLACK_LAYOUT_PROMPT, slackLayoutPromptFile, } from './layout-prompt.js'; +export * from './layout.js'; export { buildSlackBotsAliasFile, @@ -68,6 +69,7 @@ export { IntegrationAdapter, SlackAdapter, } from './slack-adapter.js'; +export * from './digest.js'; export * from './summary.js'; export * from './thread.js'; diff --git a/packages/slack/src/layout.test.ts b/packages/slack/src/layout.test.ts new file mode 100644 index 00000000..ae241bc7 --- /dev/null +++ b/packages/slack/src/layout.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { layoutManifest } from './layout.js'; + +const CANONICAL_ALIAS_SEGMENTS = new Set(['by-id', 'by-name', 'by-state', 'by-title']); + +test('layoutManifest exposes Slack resources with canonical aliases and writeback schema pointers', () => { + const manifest = layoutManifest(); + + assert.equal(manifest.provider, 'slack'); + assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-name']); + + for (const resource of manifest.resources) { + assert.ok(resource.path.startsWith('slack/')); + assert.doesNotMatch(resource.path, /^\//u); + for (const alias of resource.aliasSegments) { + assert.ok(CANONICAL_ALIAS_SEGMENTS.has(alias), `unexpected alias segment ${alias}`); + } + for (const writeback of resource.writebackResources) { + assert.ok(writeback.path.startsWith('slack/')); + assert.doesNotMatch(writeback.path, /^\//u); + assert.match(writeback.schemaId, /^slack\/[a-z-]+$/u); + } + } +}); diff --git a/packages/slack/src/layout.ts b/packages/slack/src/layout.ts new file mode 100644 index 00000000..cf086d85 --- /dev/null +++ b/packages/slack/src/layout.ts @@ -0,0 +1,48 @@ +export type MaterializationMode = 'eager' | 'lazy'; + +export interface WritebackResourceManifest { + readonly path: string; + readonly schemaId: string; +} + +export interface LayoutResourceManifest { + readonly path: string; + readonly title: string; + readonly materialization: MaterializationMode; + readonly aliasSegments: readonly string[]; + readonly writebackResources: readonly WritebackResourceManifest[]; +} + +export interface LayoutManifest { + readonly provider: string; + readonly filenameConvention: string; + readonly aliasSegments: readonly string[]; + readonly resources: readonly LayoutResourceManifest[]; +} + +export type LayoutManifestProvider = () => LayoutManifest; + +export const layoutManifest: LayoutManifestProvider = () => ({ + provider: 'slack', + filenameConvention: '__/meta.json', + aliasSegments: ['by-id', 'by-name'], + resources: [ + { + path: 'slack/channels', + title: 'Channels', + materialization: 'eager', + aliasSegments: ['by-id', 'by-name'], + writebackResources: [ + { path: 'slack/channels/messages', schemaId: 'slack/message' }, + { path: 'slack/channels/messages/reactions', schemaId: 'slack/reaction' }, + ], + }, + { + path: 'slack/users', + title: 'Users', + materialization: 'eager', + aliasSegments: ['by-id', 'by-name'], + writebackResources: [], + }, + ], +});