From eda8a45e847abd659fa1f6d93d97a03c11f40dcd Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 13 May 2026 23:00:06 +0200 Subject: [PATCH 1/2] feat(workspace-primitives): add digest() and layoutManifest() exports across first-party adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements work items 2 and 3 from AgentWorkforce/relayfile docs/workspace-primitives-spec.md (PR relayfile#147), pairing with the relayfile implementation PR that consumes these contracts. Per-provider digest() handler: packages/{linear,notion,github,slack,jira,confluence}/src/digest.ts + digest.test.ts The handler signature is the spec's contract: (ctx: DigestContext) => Promise where DigestContext/DigestSection/DigestHandler are defined locally in each provider for M1. (Hoisting these into @relayfile/adapter-core/digest-contract.ts is a follow-up; doing it now would block this PR on a core-side export change that isn't required for the relayfile digest generator to consume the per-provider results.) Implementations: - linear, notion, github: real handlers — sort by event time, render past-tense bullets with canonical mount-relative paths, return null on empty windows. - slack, jira, confluence: M1 no-op returning null, consistent with the spec's "others can no-op to null for M1" guidance. Per-provider layoutManifest() / layout.ts: packages/{linear,notion,github,slack,jira,confluence}/src/layout.ts + layout.test.ts Each provider exports a layoutManifest() that the relayfile mount daemon will call to render //.layout.md as a virtual file. Manifests describe the provider's top-level resource directories, the canonical filename convention, the by-* alias subtrees populated, and writeback subdirectories. Barrel re-exports: packages/{linear,notion,github,slack,jira,confluence}/src/index.ts Each provider's public barrel re-exports digest and layoutManifest so relayfile can import them through the standard adapter entry point. Pairs with AgentWorkforce/relayfile PR for work items 1, 4, 5, 6 (digest generator + .schema.json virtual files + writeback list CLI + dead-letter sidecars). Determinism tests in digest.test.ts assert that two runs over the same fixture produce byte-identical DigestSection outputs, satisfying WI 2 acceptance criterion 4. Generated by the workspace-primitives implementation workflow run 382654597c96e23b0b1dfa39 (run from the relayfile worktree). Per-slice fix-loop reports confirm typecheck + provider tests green (linear 101/0, github 237/0, notion 106/0, slack 85/0, jira 42/0, confluence 63/0). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/confluence/src/digest.test.ts | 22 ++++++ packages/confluence/src/digest.ts | 25 +++++++ packages/confluence/src/index.ts | 2 + packages/confluence/src/layout.test.ts | 26 +++++++ packages/confluence/src/layout.ts | 47 ++++++++++++ packages/github/src/digest.test.ts | 58 ++++++++++++++ packages/github/src/digest.ts | 100 +++++++++++++++++++++++++ packages/github/src/index.ts | 2 + packages/github/src/layout.test.ts | 27 +++++++ packages/github/src/layout.ts | 57 ++++++++++++++ packages/jira/src/digest.test.ts | 22 ++++++ packages/jira/src/digest.ts | 25 +++++++ packages/jira/src/index.ts | 2 + packages/jira/src/layout.test.ts | 26 +++++++ packages/jira/src/layout.ts | 56 ++++++++++++++ packages/linear/src/digest.test.ts | 58 ++++++++++++++ packages/linear/src/digest.ts | 97 ++++++++++++++++++++++++ packages/linear/src/index.ts | 2 + packages/linear/src/layout.test.ts | 27 +++++++ packages/linear/src/layout.ts | 55 ++++++++++++++ packages/notion/src/digest.test.ts | 58 ++++++++++++++ packages/notion/src/digest.ts | 97 ++++++++++++++++++++++++ packages/notion/src/index.ts | 2 + packages/notion/src/layout.test.ts | 27 +++++++ packages/notion/src/layout.ts | 57 ++++++++++++++ packages/slack/src/digest.test.ts | 22 ++++++ packages/slack/src/digest.ts | 25 +++++++ packages/slack/src/index.ts | 2 + packages/slack/src/layout.test.ts | 26 +++++++ packages/slack/src/layout.ts | 48 ++++++++++++ 30 files changed, 1100 insertions(+) create mode 100644 packages/confluence/src/digest.test.ts create mode 100644 packages/confluence/src/digest.ts create mode 100644 packages/confluence/src/layout.test.ts create mode 100644 packages/confluence/src/layout.ts create mode 100644 packages/github/src/digest.test.ts create mode 100644 packages/github/src/digest.ts create mode 100644 packages/github/src/layout.test.ts create mode 100644 packages/github/src/layout.ts create mode 100644 packages/jira/src/digest.test.ts create mode 100644 packages/jira/src/digest.ts create mode 100644 packages/jira/src/layout.test.ts create mode 100644 packages/jira/src/layout.ts create mode 100644 packages/linear/src/digest.test.ts create mode 100644 packages/linear/src/digest.ts create mode 100644 packages/linear/src/layout.test.ts create mode 100644 packages/linear/src/layout.ts create mode 100644 packages/notion/src/digest.test.ts create mode 100644 packages/notion/src/digest.ts create mode 100644 packages/notion/src/layout.test.ts create mode 100644 packages/notion/src/layout.ts create mode 100644 packages/slack/src/digest.test.ts create mode 100644 packages/slack/src/digest.ts create mode 100644 packages/slack/src/layout.test.ts create mode 100644 packages/slack/src/layout.ts diff --git a/packages/confluence/src/digest.test.ts b/packages/confluence/src/digest.test.ts new file mode 100644 index 00000000..8d59eb77 --- /dev/null +++ b/packages/confluence/src/digest.test.ts @@ -0,0 +1,22 @@ +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', + }, + ]; + }, + }; + + assert.equal(await digest(ctx), 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..d47d4f00 --- /dev/null +++ b/packages/github/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 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 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..826aee74 --- /dev/null +++ b/packages/github/src/digest.ts @@ -0,0 +1,100 @@ +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 { + return ( + eventTime(left).localeCompare(eventTime(right)) + || (left.id ?? '').localeCompare(right.id ?? '') + || (left.canonicalPath ?? '').localeCompare(right.canonicalPath ?? '') + ); +} + +function eventTime(event: DigestChangeEvent): string { + return event.timestamp ?? event.occurredAt ?? ''; +} + +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(); + if (/(open|opened|create|created|add|added|write|written)/u.test(action)) { + return 'was opened'; + } + if (/(close|closed|merge|merged)/u.test(action)) { + return 'was closed'; + } + if (/(delete|deleted|remove|removed)/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..ef3e3d0c --- /dev/null +++ b/packages/github/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 GitHub resources with canonical aliases and writeback schema pointers', () => { + const manifest = layoutManifest(); + + assert.equal(manifest.provider, 'github'); + assert.deepEqual(manifest.aliasSegments, ['by-id', '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); + } + } +}); diff --git a/packages/github/src/layout.ts b/packages/github/src/layout.ts new file mode 100644 index 00000000..68880720 --- /dev/null +++ b/packages/github/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: 'github', + filenameConvention: '__/meta.json', + aliasSegments: ['by-id', '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..a12124c8 --- /dev/null +++ b/packages/jira/src/digest.test.ts @@ -0,0 +1,22 @@ +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', + }, + ]; + }, + }; + + assert.equal(await digest(ctx), 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..15b171d6 --- /dev/null +++ b/packages/linear/src/digest.ts @@ -0,0 +1,97 @@ +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 { + return ( + eventTime(left).localeCompare(eventTime(right)) + || (left.id ?? '').localeCompare(right.id ?? '') + || (left.canonicalPath ?? '').localeCompare(right.canonicalPath ?? '') + ); +} + +function eventTime(event: DigestChangeEvent): string { + return event.timestamp ?? event.occurredAt ?? ''; +} + +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..f1afa85d --- /dev/null +++ b/packages/linear/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 Linear resources with canonical aliases and writeback schema pointers', () => { + const manifest = layoutManifest(); + + assert.equal(manifest.provider, 'linear'); + assert.deepEqual(manifest.aliasSegments, ['by-id', '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); + } + } +}); diff --git a/packages/linear/src/layout.ts b/packages/linear/src/layout.ts new file mode 100644 index 00000000..2a1929ce --- /dev/null +++ b/packages/linear/src/layout.ts @@ -0,0 +1,55 @@ +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', + aliasSegments: ['by-id', '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..31b60d38 --- /dev/null +++ b/packages/notion/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 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 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..a302af39 --- /dev/null +++ b/packages/notion/src/digest.ts @@ -0,0 +1,97 @@ +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.startsWith('notion/') || event.canonicalPath.startsWith('/notion/')) + ); +} + +function compareEvents(left: DigestChangeEvent, right: DigestChangeEvent): number { + return ( + eventTime(left).localeCompare(eventTime(right)) + || (left.id ?? '').localeCompare(right.id ?? '') + || (left.canonicalPath ?? '').localeCompare(right.canonicalPath ?? '') + ); +} + +function eventTime(event: DigestChangeEvent): string { + return event.timestamp ?? event.occurredAt ?? ''; +} + +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(); + if (/(create|created|add|added|write|written)/u.test(action)) { + return 'was created'; + } + if (/(delete|deleted|remove|removed|archive|archived)/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..18b74e42 --- /dev/null +++ b/packages/slack/src/digest.test.ts @@ -0,0 +1,22 @@ +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', + }, + ]; + }, + }; + + assert.equal(await digest(ctx), 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: [], + }, + ], +}); From 145491e22ae5464b65d7a26ac7f80b4b1af0387d Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 14 May 2026 10:08:39 +0200 Subject: [PATCH 2/2] fix(workspace-primitives): address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit + Devin review feedback on AgentWorkforce/relayfile-adapters#93. Bug fixes: 1. packages/github/src/digest.ts pastTense - Replace substring regex with \b word-boundary regex. The previous pattern matched "open" inside "reopened" (a real GitHub webhook action) and produced "was opened" instead of "was updated" — a semantically wrong digest line. Same fix applied to the close and delete branches for consistency. (Devin BUG-0001) - Added regression test asserting `action: "reopened"` produces "was updated". 2. packages/notion/src/digest.ts pastTense - Same word-boundary fix. The substring regex matched "archive" inside "unarchived" and produced "was archived" — the semantic inverse, which would report a restored page as archived. (Devin BUG-0002) - Added regression test asserting `action: "unarchived"` produces "was updated". 3. packages/github/src/layout.ts and packages/linear/src/layout.ts - Top-level aliasSegments was a subset of the resource-level aliasSegments. Consumers that inspect only manifest.aliasSegments to discover available alias types would miss alias keys declared by individual resources (github/repos uses by-name; linear/teams uses by-name). Top-level is now the union of every resource's alias segments. (Devin BUG-0003, BUG-0004) - Added regression tests in both layout.test.ts files asserting the top-level/resource union invariant so a future drift gets caught. 4. packages/{github,linear,notion}/src/digest.ts compareEvents - Sort by parsed timestamps (ms) rather than lexicographic ISO strings. String compare misorders timestamps describing the same instant with different textual offsets (`Z` vs `+00:00`). Added eventTimeMs helper that parses with Date.parse and falls back to NEGATIVE_INFINITY on parse failure. (CodeRabbit notion/digest.ts:67; applied consistently to github and linear because they had the same bug.) 5. packages/notion/src/digest.ts hasCanonicalPath - Add explicit equality check for "/notion" to match the existing "notion" check, so the absolute-root path isn't excluded as an inconsistent edge case. (CodeRabbit notion/digest.ts:59) - Added regression test for `canonicalPath === "/notion"`. 6. Determinism check for M1 no-op digests (packages/{confluence,jira,slack}/src/digest.test.ts) - Call `digest(ctx)` twice and assert byte-identical results, matching the determinism acceptance criterion already covered by the linear/github/notion digest tests. (CodeRabbit confluence/digest.test.ts:22; applied to jira and slack for consistency.) Skipped (with reason): - CodeRabbit `packages/{confluence,github,jira,linear,notion}/src/ layout.test.ts` alias-collision tests. These reviews ask layout.test to verify alias-collision behavior, but layout.ts only declares the manifest shape — it doesn't implement alias normalization or collision resolution. Collision behavior is owned by the path-mapper and alias-resolver code in each provider (see path-mapper.ts) and has its own dedicated tests. Adding collision tests to layout.test would test the wrong layer and create false coupling between the manifest contract and the runtime resolver. Verification: - npm test for each provider: confluence 64/0, github 241/0, jira 60/0, linear 112/0, notion 116/0, slack 85/0 - npm run typecheck for each provider: green Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/confluence/src/digest.test.ts | 7 +++- packages/github/src/digest.test.ts | 28 +++++++++++++++ packages/github/src/digest.ts | 23 +++++++++--- packages/github/src/layout.test.ts | 15 +++++++- packages/github/src/layout.ts | 5 ++- packages/jira/src/digest.test.ts | 7 +++- packages/linear/src/digest.ts | 14 +++++++- packages/linear/src/layout.test.ts | 15 +++++++- packages/linear/src/layout.ts | 5 ++- packages/notion/src/digest.test.ts | 48 ++++++++++++++++++++++++++ packages/notion/src/digest.ts | 28 ++++++++++++--- packages/slack/src/digest.test.ts | 7 +++- 12 files changed, 186 insertions(+), 16 deletions(-) diff --git a/packages/confluence/src/digest.test.ts b/packages/confluence/src/digest.test.ts index 8d59eb77..3b8d3204 100644 --- a/packages/confluence/src/digest.test.ts +++ b/packages/confluence/src/digest.test.ts @@ -18,5 +18,10 @@ test('digest returns null for Confluence M1 no-op activity windows', async () => }, }; - assert.equal(await digest(ctx), null); + // 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/github/src/digest.test.ts b/packages/github/src/digest.test.ts index d47d4f00..733edb41 100644 --- a/packages/github/src/digest.test.ts +++ b/packages/github/src/digest.test.ts @@ -45,6 +45,34 @@ test('digest returns deterministic GitHub bullets sorted by event time and id', }); }); +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', diff --git a/packages/github/src/digest.ts b/packages/github/src/digest.ts index 826aee74..a39f7535 100644 --- a/packages/github/src/digest.ts +++ b/packages/github/src/digest.ts @@ -60,8 +60,13 @@ function hasCanonicalPath(event: DigestChangeEvent): event is DigestChangeEvent } 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 ( - eventTime(left).localeCompare(eventTime(right)) + leftMs - rightMs || (left.id ?? '').localeCompare(right.id ?? '') || (left.canonicalPath ?? '').localeCompare(right.canonicalPath ?? '') ); @@ -71,6 +76,13 @@ 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, ''); } @@ -87,13 +99,16 @@ function githubIdentifier(path: string): string { function pastTense(event: DigestChangeEvent): string { const action = (event.action ?? event.eventType ?? event.type ?? '').toLowerCase(); - if (/(open|opened|create|created|add|added|write|written)/u.test(action)) { + // 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 (/(close|closed|merge|merged)/u.test(action)) { + if (/\b(close|closed|merge|merged)\b/u.test(action)) { return 'was closed'; } - if (/(delete|deleted|remove|removed)/u.test(action)) { + if (/\b(delete|deleted|remove|removed)\b/u.test(action)) { return 'was deleted'; } return 'was updated'; diff --git a/packages/github/src/layout.test.ts b/packages/github/src/layout.test.ts index ef3e3d0c..b7cf2f42 100644 --- a/packages/github/src/layout.test.ts +++ b/packages/github/src/layout.test.ts @@ -9,7 +9,7 @@ test('layoutManifest exposes GitHub resources with canonical aliases and writeba const manifest = layoutManifest(); assert.equal(manifest.provider, 'github'); - assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-title']); + assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-name', 'by-title']); assert.ok(manifest.resources.length > 0); for (const resource of manifest.resources) { @@ -25,3 +25,16 @@ test('layoutManifest exposes GitHub resources with canonical aliases and writeba } } }); + +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 index 68880720..71bfa5d1 100644 --- a/packages/github/src/layout.ts +++ b/packages/github/src/layout.ts @@ -25,7 +25,10 @@ export type LayoutManifestProvider = () => LayoutManifest; export const layoutManifest: LayoutManifestProvider = () => ({ provider: 'github', filenameConvention: '__/meta.json', - aliasSegments: ['by-id', 'by-title'], + // 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', diff --git a/packages/jira/src/digest.test.ts b/packages/jira/src/digest.test.ts index a12124c8..b4e53b03 100644 --- a/packages/jira/src/digest.test.ts +++ b/packages/jira/src/digest.test.ts @@ -18,5 +18,10 @@ test('digest returns null for Jira M1 no-op activity windows', async () => { }, }; - assert.equal(await digest(ctx), null); + // 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/linear/src/digest.ts b/packages/linear/src/digest.ts index 15b171d6..72fee523 100644 --- a/packages/linear/src/digest.ts +++ b/packages/linear/src/digest.ts @@ -60,8 +60,13 @@ function hasCanonicalPath(event: DigestChangeEvent): event is DigestChangeEvent } 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 ( - eventTime(left).localeCompare(eventTime(right)) + leftMs - rightMs || (left.id ?? '').localeCompare(right.id ?? '') || (left.canonicalPath ?? '').localeCompare(right.canonicalPath ?? '') ); @@ -71,6 +76,13 @@ 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, ''); } diff --git a/packages/linear/src/layout.test.ts b/packages/linear/src/layout.test.ts index f1afa85d..39d32f9e 100644 --- a/packages/linear/src/layout.test.ts +++ b/packages/linear/src/layout.test.ts @@ -9,7 +9,7 @@ test('layoutManifest exposes Linear resources with canonical aliases and writeba const manifest = layoutManifest(); assert.equal(manifest.provider, 'linear'); - assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-title', 'by-state']); + assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-name', 'by-title', 'by-state']); assert.ok(manifest.resources.length > 0); for (const resource of manifest.resources) { @@ -25,3 +25,16 @@ test('layoutManifest exposes Linear resources with canonical aliases and writeba } } }); + +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 index 2a1929ce..35ef07fc 100644 --- a/packages/linear/src/layout.ts +++ b/packages/linear/src/layout.ts @@ -25,7 +25,10 @@ export type LayoutManifestProvider = () => LayoutManifest; export const layoutManifest: LayoutManifestProvider = () => ({ provider: 'linear', filenameConvention: '__.json', - aliasSegments: ['by-id', 'by-title', 'by-state'], + // 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', diff --git a/packages/notion/src/digest.test.ts b/packages/notion/src/digest.test.ts index 31b60d38..b1efb751 100644 --- a/packages/notion/src/digest.test.ts +++ b/packages/notion/src/digest.test.ts @@ -45,6 +45,54 @@ test('digest returns deterministic Notion bullets sorted by event time and id', }); }); +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', diff --git a/packages/notion/src/digest.ts b/packages/notion/src/digest.ts index a302af39..0f299906 100644 --- a/packages/notion/src/digest.ts +++ b/packages/notion/src/digest.ts @@ -55,13 +55,23 @@ export const digest: DigestHandler = async (ctx) => { function hasCanonicalPath(event: DigestChangeEvent): event is DigestChangeEvent & { canonicalPath: string } { return ( typeof event.canonicalPath === 'string' - && (event.canonicalPath === 'notion' || event.canonicalPath.startsWith('notion/') || event.canonicalPath.startsWith('/notion/')) + && ( + 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 ( - eventTime(left).localeCompare(eventTime(right)) + leftMs - rightMs || (left.id ?? '').localeCompare(right.id ?? '') || (left.canonicalPath ?? '').localeCompare(right.canonicalPath ?? '') ); @@ -71,6 +81,13 @@ 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, ''); } @@ -87,10 +104,13 @@ function notionIdentifier(path: string): string { function pastTense(event: DigestChangeEvent): string { const action = (event.action ?? event.eventType ?? event.type ?? '').toLowerCase(); - if (/(create|created|add|added|write|written)/u.test(action)) { + // 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 (/(delete|deleted|remove|removed|archive|archived)/u.test(action)) { + if (/\b(delete|deleted|remove|removed|archive|archived)\b/u.test(action)) { return 'was archived'; } return 'was updated'; diff --git a/packages/slack/src/digest.test.ts b/packages/slack/src/digest.test.ts index 18b74e42..d94704ae 100644 --- a/packages/slack/src/digest.test.ts +++ b/packages/slack/src/digest.test.ts @@ -18,5 +18,10 @@ test('digest returns null for Slack M1 no-op activity windows', async () => { }, }; - assert.equal(await digest(ctx), null); + // 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); });