From 3ebbdd11d18efc4774de3f80e3209e55bc21cdfc Mon Sep 17 00:00:00 2001 From: gayoung Date: Thu, 4 Jun 2026 11:30:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EB=A0=88=ED=8F=AC=20=EB=A7=A5=EB=9D=BD=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로컬 git repo summary를 WorkItem project context로 연결 - CLI/MCP project path와 remote 옵션 추가 - README와 project context recipe 보강 - 타입체크, 테스트, release check 검증 --- README.ko.md | 26 +- README.md | 26 +- apps/cli/src/index.ts | 50 ++ apps/mcp-server/src/server.ts | 27 ++ docs/mcp.md | 14 +- docs/recipes/notion-kanban.md | 14 + docs/recipes/project-context.md | 72 +++ docs/scoring.md | 14 + packages/config/src/config.test.ts | 24 + packages/config/src/config.ts | 26 ++ packages/config/src/schema.ts | 22 + packages/core/src/brief.test.ts | 46 ++ packages/core/src/brief.ts | 16 +- packages/core/src/index.ts | 2 + packages/core/src/schema.ts | 23 +- packages/core/src/scoring.ts | 55 ++- packages/runtime/src/index.ts | 1 + packages/runtime/src/project-context.ts | 583 ++++++++++++++++++++++++ packages/runtime/src/runtime.test.ts | 48 +- packages/runtime/src/runtime.ts | 73 ++- 20 files changed, 1154 insertions(+), 8 deletions(-) create mode 100644 docs/recipes/project-context.md create mode 100644 packages/runtime/src/project-context.ts diff --git a/README.ko.md b/README.ko.md index 0e753f2..1b94e48 100644 --- a/README.ko.md +++ b/README.ko.md @@ -78,6 +78,18 @@ pnpm today --notion-board "https://www.notion.so/workspace/Tasks-0123456789abcde WorkCue는 Notion row의 title, status, due date, priority, assignee, project, labels, estimate 같은 속성을 읽습니다. 이 preview에서는 page body 내용 전체를 읽지 않습니다. +업무 source에 실제 프로젝트 레포 맥락을 함께 붙이려면: + +```bash +pnpm today \ + --notion-board "https://www.notion.so/workspace/Tasks-0123456789abcdef0123456789abcdef" \ + --project-path /path/to/project \ + --project-remote https://github.com/example/project \ + --date 2026-05-29 +``` + +Project context는 read-only로 동작합니다. WorkCue는 git branch, 변경 중인 파일, 최근 commit 제목, 주요 manifest, docs, TODO/FIXME marker를 읽고, 연결되는 업무 항목에 짧은 evidence summary만 붙입니다. 로컬 절대경로는 brief나 sync JSON에 포함하지 않습니다. + brief를 만들기 전에 정규화된 source item만 확인하려면: ```bash @@ -142,6 +154,7 @@ Top recommendation: Review PR #184: Fix payment retry race condition - GitHub Issues/PR connector - Jira issue connector - Notion kanban database/data source connector preview +- 로컬 git repo용 project context analyzer - Markdown morning brief renderer - Markdown file output - Obsidian daily note upsert @@ -149,7 +162,7 @@ Top recommendation: Review PR #184: Fix payment retry race condition - sync 결과용 로컬 SQLite cache - 로컬 container 실행용 Dockerfile - CLI commands: `workcue sync`, `workcue explain`, `workcue today --demo` -- CLI source options: `--obsidian-vault `, `--notion-board ` +- CLI source options: `--obsidian-vault `, `--notion-board `, `--project-path `, `--project-remote ` ## 제품 원칙 @@ -213,6 +226,17 @@ Notion token 값은 config에 저장하지 않고 환경변수에 둡니다. export NOTION_TOKEN="secret_..." ``` +프로젝트 레포 맥락을 config에 추가할 수도 있습니다. + +```bash +pnpm --filter workcue dev init \ + --output .workcue/config.yml \ + --project-path /path/to/project \ + --project-remote https://github.com/example/project +``` + +`.workcue/config.yml`에 private board URL, repository path, private remote URL이 들어 있다면 커밋하지 않는 것이 안전합니다. + config를 점검합니다. ```bash diff --git a/README.md b/README.md index 14a6a29..0a545cb 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,18 @@ pnpm today --notion-board "https://www.notion.so/workspace/Tasks-0123456789abcde WorkCue reads Notion row properties such as title, status, due date, priority, assignee, project, labels, and estimate. It does not read page body content in this preview. +To add project repository context to any source: + +```bash +pnpm today \ + --notion-board "https://www.notion.so/workspace/Tasks-0123456789abcdef0123456789abcdef" \ + --project-path /path/to/project \ + --project-remote https://github.com/example/project \ + --date 2026-05-29 +``` + +Project context is read-only. WorkCue looks at git branch, dirty files, recent commit subjects, common manifests, docs, and TODO/FIXME markers, then attaches a short evidence summary to matching work items. Local absolute paths are not included in the brief or sync JSON. + To inspect normalized source items without generating a brief: ```bash @@ -142,6 +154,7 @@ Top recommendation: Review PR #184: Fix payment retry race condition - GitHub Issues and PR connector package - Jira issue connector package - Notion kanban database/data source connector preview +- Project repository context analyzer for local git repos - Markdown morning brief renderer - Markdown file output - Obsidian daily note upsert @@ -149,7 +162,7 @@ Top recommendation: Review PR #184: Fix payment retry race condition - Local SQLite cache for sync results - Dockerfile for local container runs - CLI commands: `workcue sync`, `workcue explain`, `workcue today --demo` -- CLI source options: `--obsidian-vault `, `--notion-board ` +- CLI source options: `--obsidian-vault `, `--notion-board `, `--project-path `, `--project-remote ` ## Product Principles @@ -213,6 +226,17 @@ The Notion token value stays in the environment: export NOTION_TOKEN="secret_..." ``` +Add project repository context: + +```bash +pnpm --filter workcue dev init \ + --output .workcue/config.yml \ + --project-path /path/to/project \ + --project-remote https://github.com/example/project +``` + +Do not commit `.workcue/config.yml` when it contains private board URLs, repository paths, or private remote URLs. + Check the config: ```bash diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 6a253fd..4a542e7 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -15,6 +15,7 @@ import { explainWorkCueItem, renderRecommendationExplanation, runWorkCueToday, + serializeProjectContext, syncWorkCueSources, writeWorkCueOutputs, type RunWorkCueTodayOptions @@ -33,6 +34,8 @@ program .option("--output ", "Config path to write.", defaultConfigPath()) .option("--obsidian-vault ", "Local Obsidian vault path to enable.") .option("--notion-board ", "Notion kanban database or data source URL/ID to enable.") + .option("--project-path ", "Local project repository path to use as recommendation context.") + .option("--project-remote ", "Project remote URL to match GitHub/Notion/Jira work items.") .option("--markdown-output ", "Markdown output path. Supports {{date}}.") .option("--daily-note ", "Daily note output path. Supports {{date}}.") .action( @@ -40,6 +43,8 @@ program output: string; obsidianVault?: string; notionBoard?: string; + projectPath?: string; + projectRemote?: string; markdownOutput?: string; dailyNote?: string; }) => { @@ -50,6 +55,12 @@ program if (options.notionBoard) { initOptions.notionBoard = options.notionBoard; } + if (options.projectPath) { + initOptions.projectPath = options.projectPath; + } + if (options.projectRemote) { + initOptions.projectRemote = options.projectRemote; + } if (options.markdownOutput) { initOptions.markdownOutput = options.markdownOutput; } @@ -111,6 +122,10 @@ program lines.push("Notion: disabled"); } + lines.push( + config.projects.length > 0 ? `Project context: configured ${config.projects.length} project(s)` : "Project context: disabled" + ); + lines.push(config.outputs.markdown.enabled ? "Markdown output: enabled" : "Markdown output: disabled"); lines.push(config.outputs.dailyNote.enabled ? "Daily note output: enabled" : "Daily note output: disabled"); lines.push(config.cache.sqlite.enabled ? `SQLite cache: enabled ${config.cache.sqlite.path}` : "SQLite cache: disabled"); @@ -127,6 +142,8 @@ program .option("--obsidian-vault ", "Read unchecked markdown tasks from a local Obsidian vault.") .option("--notion-board ", "Read cards from a Notion kanban database or data source.") .option("--notion-token-env ", "Environment variable name that stores the Notion integration token.", "NOTION_TOKEN") + .option("--project-path ", "Local project repository path to use as recommendation context.") + .option("--project-remote ", "Project remote URL to match GitHub/Notion/Jira work items.") .option("--assignee ", "Assignee handle to attach to local tasks.", "you") .option("--date ", "Sync date in YYYY-MM-DD format.", todayDate()) .option("--json", "Print a JSON payload.") @@ -144,6 +161,8 @@ program notionTokenEnv?: string; obsidianVault?: string; output?: string; + projectPath?: string; + projectRemote?: string; }) => { const result = await syncWorkCueSources(buildRunOptions(options)); const payload = buildSyncPayload(result); @@ -179,6 +198,8 @@ program .option("--obsidian-vault ", "Read unchecked markdown tasks from a local Obsidian vault.") .option("--notion-board ", "Read cards from a Notion kanban database or data source.") .option("--notion-token-env ", "Environment variable name that stores the Notion integration token.", "NOTION_TOKEN") + .option("--project-path ", "Local project repository path to use as recommendation context.") + .option("--project-remote ", "Project remote URL to match GitHub/Notion/Jira work items.") .option("--output ", "Write the generated brief to a markdown file.") .option("--daily-note ", "Upsert the generated brief into a markdown daily note.") .option("--assignee ", "Assignee handle to attach to local tasks.", "you") @@ -193,6 +214,8 @@ program obsidianVault?: string; output?: string; dailyNote?: string; + projectPath?: string; + projectRemote?: string; assignee: string; date: string; top: number; @@ -226,6 +249,8 @@ program .option("--obsidian-vault ", "Read unchecked markdown tasks from a local Obsidian vault.") .option("--notion-board ", "Read cards from a Notion kanban database or data source.") .option("--notion-token-env ", "Environment variable name that stores the Notion integration token.", "NOTION_TOKEN") + .option("--project-path ", "Local project repository path to use as recommendation context.") + .option("--project-remote ", "Project remote URL to match GitHub/Notion/Jira work items.") .option("--assignee ", "Assignee handle to attach to local tasks.", "you") .option("--date ", "Brief date in YYYY-MM-DD format.", todayDate()) .option("--json", "Print a JSON explanation payload.") @@ -241,6 +266,8 @@ program notionBoard?: string; notionTokenEnv?: string; obsidianVault?: string; + projectPath?: string; + projectRemote?: string; } ) => { const recommendation = await explainWorkCueItem({ @@ -285,6 +312,8 @@ function buildRunOptions(options: { notionBoard?: string; notionTokenEnv?: string; obsidianVault?: string; + projectPath?: string; + projectRemote?: string; top?: number; }): RunWorkCueTodayOptions { const runOptions: RunWorkCueTodayOptions = { @@ -306,6 +335,12 @@ function buildRunOptions(options: { if (options.notionTokenEnv) { runOptions.notionTokenEnv = options.notionTokenEnv; } + if (options.projectPath) { + runOptions.projectPath = options.projectPath; + } + if (options.projectRemote) { + runOptions.projectRemote = options.projectRemote; + } if (options.top) { runOptions.top = options.top; } @@ -317,6 +352,7 @@ function buildSyncPayload(result: Awaited> syncedAt: result.syncedAt, itemCount: result.items.length, sourceCounts: result.sourceCounts, + projectContexts: result.projectContexts.map(serializeProjectContext), items: result.items.map(serializeWorkItem) }; } @@ -400,6 +436,20 @@ function serializeWorkItem(item: Awaited>[ if (item.estimateMinutes) { payload.estimateMinutes = item.estimateMinutes; } + if (item.projectContexts && item.projectContexts.length > 0) { + payload.projectContexts = item.projectContexts.map((context) => ({ + projectId: context.projectId, + ...(context.projectName ? { projectName: context.projectName } : {}), + ...(context.repoName ? { repoName: context.repoName } : {}), + ...(context.currentBranch ? { currentBranch: context.currentBranch } : {}), + ...(typeof context.isDirty === "boolean" ? { isDirty: context.isDirty } : {}), + signals: context.signals, + matchedTerms: context.matchedTerms.slice(0, 8), + matchedFiles: context.matchedFiles.slice(0, 8), + ...(typeof context.changedFileCount === "number" ? { changedFileCount: context.changedFileCount } : {}), + ...(typeof context.todoCount === "number" ? { todoCount: context.todoCount } : {}) + })); + } return payload; } diff --git a/apps/mcp-server/src/server.ts b/apps/mcp-server/src/server.ts index a35b6f4..221747b 100644 --- a/apps/mcp-server/src/server.ts +++ b/apps/mcp-server/src/server.ts @@ -5,6 +5,7 @@ import { explainWorkCueItem, renderRecommendationExplanation, runWorkCueToday, + serializeProjectContext, syncWorkCueSources, writeWorkCueOutputs, type RunWorkCueTodayOptions @@ -19,6 +20,8 @@ const SourceToolArgsSchema = z.object({ notionBoard: z.string().optional().describe("Notion kanban database or data source URL/ID to read cards from."), notionTokenEnv: z.string().optional().describe("Environment variable name that stores the Notion integration token."), obsidianVault: z.string().optional().describe("Local Obsidian vault path to read unchecked markdown tasks from."), + projectPath: z.string().optional().describe("Local project repository path to use as recommendation context."), + projectRemote: z.string().optional().describe("Project remote URL to match GitHub/Notion/Jira work items."), top: z.number().int().positive().optional().describe("Number of focus items to return.") }); @@ -132,6 +135,7 @@ export async function runTodayTool(args: TodayToolArgs): Promise { "", `- Items read: ${result.items.length}`, `- Source counts: ${formatSourceCounts(result.sourceCounts)}`, + `- Project contexts: ${result.projectContexts.length}`, `- Outputs written: ${outputsWritten}` ].join("\n"); } @@ -206,6 +210,8 @@ function buildRunOptions( notionBoard?: string | undefined; notionTokenEnv?: string | undefined; obsidianVault?: string | undefined; + projectPath?: string | undefined; + projectRemote?: string | undefined; top?: number | undefined; }, date: string @@ -226,6 +232,12 @@ function buildRunOptions( if (args.notionTokenEnv) { runOptions.notionTokenEnv = args.notionTokenEnv; } + if (args.projectPath) { + runOptions.projectPath = args.projectPath; + } + if (args.projectRemote) { + runOptions.projectRemote = args.projectRemote; + } if (args.top) { runOptions.top = args.top; } @@ -240,6 +252,7 @@ function buildSyncPayload(result: Awaited> syncedAt: result.syncedAt, itemCount: result.items.length, sourceCounts: result.sourceCounts, + projectContexts: result.projectContexts.map(serializeProjectContext), items: result.items.map(serializeWorkItem) }; } @@ -279,6 +292,20 @@ function serializeWorkItem(item: Awaited>[ if (item.estimateMinutes) { payload.estimateMinutes = item.estimateMinutes; } + if (item.projectContexts && item.projectContexts.length > 0) { + payload.projectContexts = item.projectContexts.map((context) => ({ + projectId: context.projectId, + ...(context.projectName ? { projectName: context.projectName } : {}), + ...(context.repoName ? { repoName: context.repoName } : {}), + ...(context.currentBranch ? { currentBranch: context.currentBranch } : {}), + ...(typeof context.isDirty === "boolean" ? { isDirty: context.isDirty } : {}), + signals: context.signals, + matchedTerms: context.matchedTerms.slice(0, 8), + matchedFiles: context.matchedFiles.slice(0, 8), + ...(typeof context.changedFileCount === "number" ? { changedFileCount: context.changedFileCount } : {}), + ...(typeof context.todoCount === "number" ? { todoCount: context.todoCount } : {}) + })); + } return payload; } diff --git a/docs/mcp.md b/docs/mcp.md index 997cb32..58608e9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -72,6 +72,18 @@ One-off Notion board: } ``` +Notion board with local project context: + +```json +{ + "notionBoard": "https://www.notion.so/workspace/Tasks-0123456789abcdef0123456789abcdef", + "notionTokenEnv": "NOTION_TOKEN", + "projectPath": "/path/to/project", + "projectRemote": "https://github.com/example/project", + "date": "2026-05-29" +} +``` + Explain one item: ```json @@ -84,4 +96,4 @@ Explain one item: ## Data Exposure -`workcue_sync` returns normalized item summaries and excludes connector `raw` payloads. Avoid sending generated briefs or sync JSON to shared channels if your source tools contain sensitive work. +`workcue_sync` returns normalized item summaries and excludes connector `raw` payloads. Project context summaries do not include local absolute paths, but they can include repository names, branch names, relative file names, and recent commit subjects. Avoid sending generated briefs or sync JSON to shared channels if your source tools contain sensitive work. diff --git a/docs/recipes/notion-kanban.md b/docs/recipes/notion-kanban.md index 2c5edc8..a01f4f4 100644 --- a/docs/recipes/notion-kanban.md +++ b/docs/recipes/notion-kanban.md @@ -55,6 +55,20 @@ pnpm doctor --config .workcue/config.yml pnpm today --config .workcue/config.yml --date 2026-05-29 ``` +## Add Repository Context + +If the Notion board tracks work for a specific repository, add project context so WorkCue can consider the real codebase state: + +```bash +pnpm today \ + --notion-board "https://www.notion.so/workspace/Tasks-0123456789abcdef0123456789abcdef" \ + --project-path /path/to/project \ + --project-remote https://github.com/example/project \ + --date 2026-05-29 +``` + +See [Project Context](./project-context.md) for the reusable config format. + ## Property Mapping If your board uses different property names, edit `.workcue/config.yml`: diff --git a/docs/recipes/project-context.md b/docs/recipes/project-context.md new file mode 100644 index 0000000..858b389 --- /dev/null +++ b/docs/recipes/project-context.md @@ -0,0 +1,72 @@ +# Recipe: Project Context + +Project context lets WorkCue combine existing work items with the real repository they belong to. This is useful when your source of truth is a Notion board, Jira sprint, GitHub PR list, or Obsidian note, but the next action depends on the actual codebase state. + +WorkCue does not turn the repository into a task database. It reads a small local summary and uses it as recommendation evidence. + +## What WorkCue Reads + +When `--project-path` or `projects[].repo.localPath` is configured, WorkCue reads: + +- current git branch +- dirty worktree file names from `git status --short` +- recent commit subjects from `git log` +- common manifests such as `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, and `Dockerfile` +- docs file names such as `README.md`, `CHANGELOG.md`, and files under `docs/` +- TODO/FIXME/XXX marker locations + +WorkCue does not include local absolute paths in brief markdown or sync JSON. It does not send repository contents to an LLM unless you explicitly build a separate workflow that does so. + +## One-Off Run + +```bash +pnpm today \ + --notion-board "https://www.notion.so/workspace/Tasks-0123456789abcdef0123456789abcdef" \ + --project-path /path/to/project \ + --project-remote https://github.com/example/project \ + --date 2026-05-29 +``` + +`--project-remote` helps WorkCue match GitHub URLs and cards that mention the repository. If omitted, WorkCue tries to read `origin` from the local git repository. + +## Reusable Config + +```yaml +projects: + - id: app + name: App + repo: + localPath: /path/to/project + remoteUrl: https://github.com/example/project + match: + keywords: + - onboarding + - billing + labels: + - frontend + sourceUrls: + - https://github.com/example/project +``` + +Then run: + +```bash +pnpm today --config .workcue/config.yml --date 2026-05-29 +``` + +## How Matching Works + +WorkCue attaches project context to a work item when it finds evidence such as: + +- a GitHub issue or PR URL from the same repository +- project names, labels, issue keys, or keywords shared by the work item and configured project +- a current branch, changed file, recent commit, or TODO marker that matches terms from the work item + +The scoring signal is `project_context`. It is weaker than due dates and review blockers when the only evidence is a repo URL, and stronger when active branch or dirty worktree evidence exists. + +## Privacy + +- Keep `.workcue/config.yml` out of git when it contains private paths or links. +- Token values still belong in environment variables. +- Briefs and sync JSON summarize repo context with repository name, branch, relative file names, and counts. +- Page body reads for Notion are still out of scope in this preview. diff --git a/docs/scoring.md b/docs/scoring.md index 7fc141c..4c54233 100644 --- a/docs/scoring.md +++ b/docs/scoring.md @@ -18,6 +18,7 @@ Examples: - `waiting_external` - `quick_win` - `deep_work` +- `project_context` Each signal has a default weight in `@workcue/core`. The final item score is the sum of the signal weights. @@ -44,3 +45,16 @@ pnpm --filter workcue start explain github:pr-184 --demo --date 2026-05-29 ``` When a multiplier changes a signal, the signal evidence includes the default weight and multiplier. + +## Project Context Signal + +`project_context` is added when a work item matches a configured project repository. + +Examples: + +- source URL points to the same GitHub repository +- current branch contains a matching issue key or task keyword +- dirty files or TODO markers appear in a matching repository +- recent commit subjects mention a matching issue key or task keyword + +This signal is intentionally bounded. A simple repo URL match is a weak signal, while active branch, dirty worktree, or recent commit evidence is stronger. diff --git a/packages/config/src/config.test.ts b/packages/config/src/config.test.ts index e039106..f2aafa6 100644 --- a/packages/config/src/config.test.ts +++ b/packages/config/src/config.test.ts @@ -15,6 +15,7 @@ describe("config", () => { expect(config.sources.notion.enabled).toBe(false); expect(config.sources.notion.tokenEnv).toBe("NOTION_TOKEN"); expect(config.sources.notion.boards).toEqual([]); + expect(config.projects).toEqual([]); expect(config.cache.sqlite.enabled).toBe(true); expect(config.cache.sqlite.path).toBe(".workcue/workcue.sqlite"); expect(config.scoring.signalWeights).toEqual({}); @@ -44,6 +45,29 @@ describe("config", () => { }); }); + it("creates a project context config when requested", () => { + const config = createInitialConfig({ + projectPath: "/path/to/project", + projectRemote: "https://github.com/example/project" + }); + + expect(config.projects).toEqual([ + { + id: "default", + name: "Default project", + repo: { + localPath: "/path/to/project", + remoteUrl: "https://github.com/example/project" + }, + match: { + keywords: [], + labels: [], + sourceUrls: [] + } + } + ]); + }); + it("writes and loads config files", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "workcue-config-")); const configPath = path.join(root, "config.yml"); diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts index 8753552..4d18db3 100644 --- a/packages/config/src/config.ts +++ b/packages/config/src/config.ts @@ -9,6 +9,8 @@ export interface InitConfigOptions { markdownOutput?: string; dailyNote?: string; notionBoard?: string; + projectPath?: string; + projectRemote?: string; } export function defaultConfigPath(): string { @@ -39,6 +41,7 @@ export function createInitialConfig(options: InitConfigOptions = {}): WorkCueCon user: { handles: ["you"] }, + projects: buildInitialProjects(options), sources: { github: { enabled: false, @@ -107,6 +110,29 @@ export function createInitialConfig(options: InitConfigOptions = {}): WorkCueCon }); } +function buildInitialProjects(options: InitConfigOptions): WorkCueConfig["projects"] { + if (!options.projectPath && !options.projectRemote) { + return []; + } + const project: WorkCueConfig["projects"][number] = { + id: "default", + name: "Default project", + repo: {}, + match: { + keywords: [], + labels: [], + sourceUrls: [] + } + }; + if (options.projectPath) { + project.repo.localPath = options.projectPath; + } + if (options.projectRemote) { + project.repo.remoteUrl = options.projectRemote; + } + return [project]; +} + export function expandDateTemplate(value: string | undefined, date: string): string | undefined { return value?.replaceAll("{{date}}", date); } diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 11cf1e8..a9764e8 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -20,6 +20,28 @@ export const WorkCueConfigSchema = z.object({ handles: z.array(z.string()).default(["you"]) }) .default({}), + projects: z + .array( + z.object({ + id: z.string().min(1), + name: z.string().optional(), + repo: z + .object({ + localPath: z.string().optional(), + remoteUrl: z.string().optional(), + defaultBranch: z.string().optional() + }) + .default({}), + match: z + .object({ + keywords: z.array(z.string()).default([]), + labels: z.array(z.string()).default([]), + sourceUrls: z.array(z.string()).default([]) + }) + .default({}) + }) + ) + .default([]), sources: z .object({ github: z diff --git a/packages/core/src/brief.test.ts b/packages/core/src/brief.test.ts index f3778fc..86e06ff 100644 --- a/packages/core/src/brief.test.ts +++ b/packages/core/src/brief.test.ts @@ -81,4 +81,50 @@ describe("demo brief", () => { ]) ); }); + + it("adds project context evidence when a work item matches a repo", () => { + const [recommendation] = rankWorkItems( + [ + { + id: "notion:task-1", + source: "notion", + sourceId: "task-1", + title: "Finish auth cleanup", + status: "todo", + assignees: ["you"], + labels: ["auth"], + projectContexts: [ + { + projectId: "app", + repoName: "app", + currentBranch: "feature/auth-cleanup", + isDirty: true, + signals: ["active_branch", "dirty_worktree"], + matchedTerms: ["auth"], + matchedFiles: ["src/auth.ts"], + recentCommitSubjects: ["feat: auth cleanup"], + manifestFiles: ["package.json"], + docFiles: ["README.md"], + todoFiles: [], + todoCount: 0 + } + ] + } + ], + { + date: "2026-05-29", + userHandles: ["you"] + } + ); + + expect(recommendation?.reasons).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "project_context", + weight: 55, + message: "app 프로젝트 레포 맥락과 연결된 작업입니다. 현재 branch: feature/auth-cleanup. 변경 중인 파일이 있습니다." + }) + ]) + ); + }); }); diff --git a/packages/core/src/brief.ts b/packages/core/src/brief.ts index 0d7fad1..692b252 100644 --- a/packages/core/src/brief.ts +++ b/packages/core/src/brief.ts @@ -85,12 +85,14 @@ function renderRecommendations(recommendations: Recommendation[]): string[] { const source = recommendation.workItem.sourceUrl ? `${recommendation.workItem.source}: ${recommendation.workItem.sourceUrl}` : `${recommendation.workItem.source}: ${recommendation.workItem.sourceId}`; + const visibleReasons = recommendation.reasons.filter((reason) => reason.kind !== "project_context").slice(0, 4); return [ `${recommendation.rank}. ${recommendation.workItem.title}`, ` Score: ${recommendation.score}`, " Why now:", - ...recommendation.reasons.slice(0, 4).map((reason) => ` - ${reason.message}`), + ...visibleReasons.map((reason) => ` - ${reason.message}`), + ...renderProjectContextLines(recommendation), " Suggested action:", ` - ${recommendation.suggestedAction}`, " Source:", @@ -100,6 +102,18 @@ function renderRecommendations(recommendations: Recommendation[]): string[] { }); } +function renderProjectContextLines(recommendation: Recommendation): string[] { + const contexts = recommendation.workItem.projectContexts ?? []; + if (contexts.length === 0) { + return []; + } + return contexts.slice(0, 2).map((context) => { + const repo = context.repoName ?? context.projectName ?? context.projectId; + const branch = context.currentBranch ? `, branch ${context.currentBranch}` : ""; + return ` - Project context: ${repo}${branch}`; + }); +} + function buildSummary(focus: Recommendation[]): string { const top = focus[0]; if (!top) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b84b1d0..52cbaed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,7 @@ export type { SignalKind, WorkItem, WorkItemPriority, + WorkItemProjectContext, WorkItemSource, WorkItemStatus } from "./schema.js"; @@ -19,6 +20,7 @@ export { SignalKindSchema, SignalSchema, WorkItemPrioritySchema, + WorkItemProjectContextSchema, WorkItemSchema, WorkItemSourceSchema, WorkItemStatusSchema diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 22e5ab3..c1afac7 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -24,6 +24,24 @@ export const WorkItemSourceSchema = z.enum([ export const WorkItemPrioritySchema = z.enum(["low", "medium", "high", "urgent"]); +export const WorkItemProjectContextSchema = z.object({ + projectId: z.string().min(1), + projectName: z.string().optional(), + repoName: z.string().optional(), + currentBranch: z.string().optional(), + defaultBranch: z.string().optional(), + isDirty: z.boolean().optional(), + signals: z.array(z.string()).default([]), + matchedTerms: z.array(z.string()).default([]), + matchedFiles: z.array(z.string()).default([]), + recentCommitSubjects: z.array(z.string()).default([]), + manifestFiles: z.array(z.string()).default([]), + docFiles: z.array(z.string()).default([]), + todoFiles: z.array(z.string()).default([]), + changedFileCount: z.number().int().nonnegative().optional(), + todoCount: z.number().int().nonnegative().optional() +}); + export const WorkItemSchema = z.object({ id: z.string().min(1), source: WorkItemSourceSchema, @@ -48,6 +66,7 @@ export const WorkItemSchema = z.object({ parentId: z.string().optional(), blockedBy: z.array(z.string()).optional(), blocking: z.array(z.string()).optional(), + projectContexts: z.array(WorkItemProjectContextSchema).optional(), raw: z.unknown().optional() }); @@ -64,7 +83,8 @@ export const SignalKindSchema = z.enum([ "blocked", "waiting_external", "quick_win", - "deep_work" + "deep_work", + "project_context" ]); export const SignalSchema = z.object({ @@ -112,6 +132,7 @@ export const BriefSchema = z.object({ export type WorkItemStatus = z.infer; export type WorkItemSource = z.infer; export type WorkItemPriority = z.infer; +export type WorkItemProjectContext = z.infer; export type WorkItem = z.infer; export type SignalKind = z.infer; export type Signal = z.infer; diff --git a/packages/core/src/scoring.ts b/packages/core/src/scoring.ts index 96bedb9..ec422f0 100644 --- a/packages/core/src/scoring.ts +++ b/packages/core/src/scoring.ts @@ -1,4 +1,11 @@ -import type { Recommendation, RecommendationMode, Signal, SignalKind, WorkItem } from "./schema.js"; +import type { + Recommendation, + RecommendationMode, + Signal, + SignalKind, + WorkItem, + WorkItemProjectContext +} from "./schema.js"; export interface ScoreOptions { date: string; @@ -146,6 +153,13 @@ function scoreWorkItem(item: WorkItem, options: ScoreOptions): ScoreBreakdown { addSignal("waiting_external", -40, "외부 응답을 기다리는 작업입니다.", { status: item.status, labels: item.labels }); } + if (item.projectContexts && item.projectContexts.length > 0) { + const contextWeight = Math.max(...item.projectContexts.map(scoreProjectContext)); + addSignal("project_context", contextWeight, buildProjectContextMessage(item.projectContexts), { + projectContexts: item.projectContexts.map(serializeProjectContextEvidence) + }); + } + const score = signals.reduce((sum, signal) => sum + signal.weight, 0); const positiveSignals = signals.filter((signal) => signal.weight > 0).length; const confidence = Math.min(0.95, 0.45 + positiveSignals * 0.1); @@ -189,6 +203,45 @@ function buildSuggestedAction(item: WorkItem, mode: RecommendationMode): string return "오늘 완료 가능한 가장 작은 다음 행동부터 진행하세요."; } +function scoreProjectContext(context: WorkItemProjectContext): number { + const strongSignals = ["active_branch", "dirty_worktree", "changed_file", "recent_commit"]; + if (context.signals.some((signal) => strongSignals.includes(signal))) { + return 55; + } + if (context.signals.includes("todo_marker") || context.signals.includes("project_keyword")) { + return 40; + } + return 25; +} + +function buildProjectContextMessage(contexts: WorkItemProjectContext[]): string { + const context = contexts[0]; + if (!context) { + return "연결된 프로젝트 레포 맥락이 감지되었습니다."; + } + const repo = context.repoName ?? context.projectName ?? context.projectId; + const branch = context.currentBranch ? ` 현재 branch: ${context.currentBranch}.` : ""; + const dirty = context.isDirty ? " 변경 중인 파일이 있습니다." : ""; + return `${repo} 프로젝트 레포 맥락과 연결된 작업입니다.${branch}${dirty}`; +} + +function serializeProjectContextEvidence(context: WorkItemProjectContext): Record { + return { + projectId: context.projectId, + ...(context.projectName ? { projectName: context.projectName } : {}), + ...(context.repoName ? { repoName: context.repoName } : {}), + ...(context.currentBranch ? { currentBranch: context.currentBranch } : {}), + ...(context.defaultBranch ? { defaultBranch: context.defaultBranch } : {}), + ...(typeof context.isDirty === "boolean" ? { isDirty: context.isDirty } : {}), + signals: context.signals, + matchedTerms: context.matchedTerms.slice(0, 8), + matchedFiles: context.matchedFiles.slice(0, 8), + recentCommitSubjects: context.recentCommitSubjects.slice(0, 5), + ...(typeof context.changedFileCount === "number" ? { changedFileCount: context.changedFileCount } : {}), + ...(typeof context.todoCount === "number" ? { todoCount: context.todoCount } : {}) + }; +} + function isAssignedToUser(item: WorkItem, handles: string[] = []): boolean { if (handles.length === 0) { return item.assignees.length > 0; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 33f8358..284292b 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,6 +1,7 @@ export { explainWorkCueItem, renderRecommendationExplanation, + serializeProjectContext, WorkCueRuntimeError, runWorkCueToday, syncWorkCueSources, diff --git a/packages/runtime/src/project-context.ts b/packages/runtime/src/project-context.ts new file mode 100644 index 0000000..f25008e --- /dev/null +++ b/packages/runtime/src/project-context.ts @@ -0,0 +1,583 @@ +import { execFile } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; +import { access, readFile, readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import type { WorkCueConfig } from "@workcue/config"; +import type { WorkItem, WorkItemProjectContext } from "@workcue/core"; + +const execFileAsync = promisify(execFile); + +const DEFAULT_BRANCHES = new Set(["main", "master", "trunk"]); +const DEFAULT_MAX_SCAN_FILES = 500; +const MAX_TEXT_BYTES = 256 * 1024; +const SKIP_DIRECTORIES = new Set([ + ".git", + ".hg", + ".svn", + ".next", + ".turbo", + ".workcue", + "_workspace", + "coverage", + "dist", + "node_modules", + "out", + "target" +]); +const TEXT_EXTENSIONS = new Set([ + ".c", + ".cc", + ".cpp", + ".cs", + ".css", + ".go", + ".h", + ".java", + ".js", + ".json", + ".jsx", + ".kt", + ".md", + ".mdx", + ".php", + ".py", + ".rb", + ".rs", + ".scss", + ".swift", + ".toml", + ".ts", + ".tsx", + ".txt", + ".yaml", + ".yml" +]); +const MANIFEST_FILES = new Set([ + "Cargo.toml", + "Dockerfile", + "go.mod", + "package.json", + "pnpm-workspace.yaml", + "pyproject.toml", + "requirements.txt", + "tsconfig.json" +]); +const STOP_WORDS = new Set([ + "and", + "the", + "for", + "with", + "from", + "github", + "this", + "that", + "into", + "jira", + "linear", + "notion", + "obsidian", + "todo", + "task", + "work", + "review", + "finish", + "issue", + "bug", + "cleanup", + "current", + "customer", + "daily", + "design", + "feedback", + "fix", + "issue", + "issues", + "navigation", + "note", + "project", + "release", + "settings", + "sprint", + "waiting" +]); + +export type ProjectConfig = WorkCueConfig["projects"][number]; + +export interface ProjectContextSummary { + projectId: string; + projectName?: string; + repoName?: string; + repoSlug?: string; + currentBranch?: string; + defaultBranch?: string; + isDirty?: boolean; + changedFiles: string[]; + recentCommitSubjects: string[]; + manifestFiles: string[]; + docFiles: string[]; + todoFiles: string[]; + todoCount: number; + matchKeywords: string[]; + matchLabels: string[]; + sourceUrlPrefixes: string[]; + scanError?: string; +} + +export interface CollectProjectContextsOptions { + maxScanFiles?: number; + projects: ProjectConfig[]; +} + +export async function collectProjectContexts(options: CollectProjectContextsOptions): Promise { + const summaries: ProjectContextSummary[] = []; + for (const project of options.projects) { + summaries.push(await scanProject(project, options.maxScanFiles ?? DEFAULT_MAX_SCAN_FILES)); + } + return summaries; +} + +export function enrichWorkItemsWithProjectContexts( + items: WorkItem[], + contexts: ProjectContextSummary[] +): WorkItem[] { + if (contexts.length === 0) { + return items; + } + return items.map((item) => { + const matches = contexts + .map((context) => matchProjectContext(item, context)) + .filter((context): context is WorkItemProjectContext => Boolean(context)); + + if (matches.length === 0) { + return item; + } + return { + ...item, + projectContexts: [...(item.projectContexts ?? []), ...matches] + }; + }); +} + +export function serializeProjectContextSummary(context: ProjectContextSummary): Record { + return { + projectId: context.projectId, + ...(context.projectName ? { projectName: context.projectName } : {}), + ...(context.repoName ? { repoName: context.repoName } : {}), + ...(context.repoSlug ? { repoSlug: context.repoSlug } : {}), + ...(context.currentBranch ? { currentBranch: context.currentBranch } : {}), + ...(context.defaultBranch ? { defaultBranch: context.defaultBranch } : {}), + ...(typeof context.isDirty === "boolean" ? { isDirty: context.isDirty } : {}), + changedFileCount: context.changedFiles.length, + recentCommitSubjects: context.recentCommitSubjects.slice(0, 5), + manifestFiles: context.manifestFiles.slice(0, 10), + docFiles: context.docFiles.slice(0, 10), + todoFiles: context.todoFiles.slice(0, 10), + todoCount: context.todoCount, + ...(context.scanError ? { scanError: context.scanError } : {}) + }; +} + +async function scanProject(project: ProjectConfig, maxScanFiles: number): Promise { + const configuredRemote = project.repo.remoteUrl; + const configuredPath = project.repo.localPath; + const configuredSlug = configuredRemote ? extractRepoSlug(configuredRemote) : undefined; + const summary: ProjectContextSummary = { + projectId: project.id, + changedFiles: [], + recentCommitSubjects: [], + manifestFiles: [], + docFiles: [], + todoFiles: [], + todoCount: 0, + matchKeywords: project.match.keywords.map(normalizeTerm).filter(Boolean), + matchLabels: project.match.labels.map(normalizeTerm).filter(Boolean), + sourceUrlPrefixes: project.match.sourceUrls + }; + + if (project.name) { + summary.projectName = project.name; + } + if (project.repo.defaultBranch) { + summary.defaultBranch = project.repo.defaultBranch; + } + if (configuredSlug) { + summary.repoSlug = configuredSlug; + const repoName = configuredSlug.split("/").at(-1); + if (repoName) { + summary.repoName = repoName; + } + } + + if (!configuredPath) { + return summary; + } + + const resolvedPath = path.resolve(configuredPath); + if (!(await canAccess(resolvedPath))) { + summary.scanError = "local path not accessible"; + return summary; + } + + const gitRoot = await readGitOutput(resolvedPath, ["rev-parse", "--show-toplevel"]); + const repoRoot = gitRoot ? path.resolve(gitRoot) : resolvedPath; + const gitRemote = configuredRemote ?? (await readGitOutput(repoRoot, ["remote", "get-url", "origin"])); + const gitSlug = gitRemote ? extractRepoSlug(gitRemote) : undefined; + if (gitSlug) { + summary.repoSlug = gitSlug; + const repoName = gitSlug.split("/").at(-1); + if (repoName) { + summary.repoName = repoName; + } + } else { + summary.repoName = path.basename(repoRoot); + } + + const branch = await readGitOutput(repoRoot, ["branch", "--show-current"]); + if (branch) { + summary.currentBranch = branch; + } + const status = await readGitOutput(repoRoot, ["status", "--short"]); + if (status) { + summary.changedFiles = parseGitStatusFiles(status); + summary.isDirty = summary.changedFiles.length > 0; + } else { + summary.isDirty = false; + } + const commitLog = await readGitOutput(repoRoot, ["log", "-8", "--pretty=format:%s"]); + if (commitLog) { + summary.recentCommitSubjects = commitLog + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 8); + } + + const files = await collectRepoFiles(repoRoot, maxScanFiles); + summary.manifestFiles = files.filter((file) => MANIFEST_FILES.has(path.basename(file))).slice(0, 20); + summary.docFiles = files.filter(isDocFile).slice(0, 20); + const todoResult = await scanTodoMarkers(repoRoot, files); + summary.todoFiles = todoResult.files; + summary.todoCount = todoResult.count; + return summary; +} + +function matchProjectContext(item: WorkItem, context: ProjectContextSummary): WorkItemProjectContext | undefined { + const terms = buildWorkItemTerms(item); + const signals = new Set(); + const matchedTerms = new Set(); + const matchedFiles = new Set(); + + if (item.sourceUrl && matchesSourceUrl(item.sourceUrl, context)) { + signals.add("source_url"); + } + + const projectTerms = buildProjectTerms(context); + for (const term of projectTerms) { + if (term && terms.has(term)) { + signals.add("project_keyword"); + matchedTerms.add(term); + } + } + + const branch = normalizeTerm(context.currentBranch ?? ""); + if (branch && termSetMatchesText(terms, branch)) { + signals.add("active_branch"); + for (const term of terms) { + if (branch.includes(term)) { + matchedTerms.add(term); + } + } + } else if ( + context.currentBranch && + !DEFAULT_BRANCHES.has(context.currentBranch) && + signals.has("source_url") + ) { + signals.add("active_branch"); + } + + for (const subject of context.recentCommitSubjects) { + const normalized = normalizeTerm(subject); + if (termSetMatchesText(terms, normalized)) { + signals.add("recent_commit"); + for (const term of terms) { + if (normalized.includes(term)) { + matchedTerms.add(term); + } + } + } + } + + if (signals.has("source_url") && context.isDirty) { + signals.add("dirty_worktree"); + } + + for (const file of context.changedFiles) { + const normalized = normalizeTerm(file); + if (termSetMatchesText(terms, normalized)) { + signals.add("changed_file"); + matchedFiles.add(file); + } + } + + for (const file of context.todoFiles) { + const normalized = normalizeTerm(file); + if (termSetMatchesText(terms, normalized)) { + signals.add("todo_marker"); + matchedFiles.add(file); + } + } + + if (signals.size === 0) { + return undefined; + } + + const match: WorkItemProjectContext = { + projectId: context.projectId, + signals: [...signals].sort(), + matchedTerms: [...matchedTerms].sort(), + matchedFiles: [...matchedFiles].sort(), + recentCommitSubjects: context.recentCommitSubjects.slice(0, 5), + manifestFiles: context.manifestFiles.slice(0, 10), + docFiles: context.docFiles.slice(0, 10), + todoFiles: context.todoFiles.slice(0, 10), + todoCount: context.todoCount + }; + if (context.projectName) { + match.projectName = context.projectName; + } + if (context.repoName) { + match.repoName = context.repoName; + } + if (context.currentBranch) { + match.currentBranch = context.currentBranch; + } + if (context.defaultBranch) { + match.defaultBranch = context.defaultBranch; + } + if (typeof context.isDirty === "boolean") { + match.isDirty = context.isDirty; + } + if (context.changedFiles.length > 0) { + match.changedFileCount = context.changedFiles.length; + } + return match; +} + +function buildWorkItemTerms(item: WorkItem): Set { + const values = [ + item.sourceId, + item.title, + item.body, + item.project, + item.milestone, + item.sprint, + ...item.labels + ].filter((value): value is string => Boolean(value)); + + const terms = new Set(); + for (const value of values) { + for (const term of extractTerms(value)) { + terms.add(term); + } + } + return terms; +} + +function buildProjectTerms(context: ProjectContextSummary): Set { + const values = [ + context.projectId, + context.projectName, + context.repoName && context.repoName.length >= 4 ? context.repoName : undefined, + ...context.matchKeywords, + ...context.matchLabels + ].filter((value): value is string => Boolean(value)); + const terms = new Set(); + for (const value of values) { + for (const term of extractTerms(value)) { + terms.add(term); + } + } + return terms; +} + +function matchesSourceUrl(sourceUrl: string, context: ProjectContextSummary): boolean { + if (context.sourceUrlPrefixes.some((prefix) => sourceUrl.startsWith(prefix))) { + return true; + } + if (!context.repoSlug) { + return false; + } + return extractRepoSlug(sourceUrl) === context.repoSlug; +} + +function extractTerms(value: string): string[] { + const normalized = normalizeTerm(value); + const terms = new Set(); + const issueKeys = value.match(/[A-Z][A-Z0-9]+-\d+/g) ?? []; + for (const key of issueKeys) { + terms.add(key.toLowerCase()); + } + const references = value.match(/#\d+/g) ?? []; + for (const reference of references) { + terms.add(reference.toLowerCase()); + terms.add(reference.slice(1)); + } + for (const chunk of normalized.split(/[^a-z0-9가-힣]+/u)) { + if ((chunk.length >= 4 || /[가-힣]{2,}/u.test(chunk)) && !STOP_WORDS.has(chunk)) { + terms.add(chunk); + } + } + return [...terms]; +} + +function normalizeTerm(value: string): string { + return value.trim().toLowerCase(); +} + +function termSetMatchesText(terms: Set, text: string): boolean { + for (const term of terms) { + if (term.length >= 3 && text.includes(term)) { + return true; + } + } + return false; +} + +function extractRepoSlug(value: string): string | undefined { + const trimmed = value.trim().replace(/\.git$/u, ""); + const sshMatch = trimmed.match(/github\.com[:/]([^/\s]+\/[^/\s?#]+)/iu); + if (sshMatch?.[1]) { + return sshMatch[1].toLowerCase(); + } + try { + const url = new URL(trimmed); + if (url.hostname !== "github.com") { + return undefined; + } + const segments = url.pathname + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + if (segments.length >= 2 && segments[0] && segments[1]) { + return `${segments[0]}/${segments[1]}`.toLowerCase().replace(/\.git$/u, ""); + } + } catch { + return undefined; + } + return undefined; +} + +function parseGitStatusFiles(value: string): string[] { + return value + .split("\n") + .map((line) => line.slice(3).trim()) + .filter(Boolean) + .map((file) => file.replace(/^"|"$/gu, "")) + .slice(0, 50); +} + +async function readGitOutput(cwd: string, args: string[]): Promise { + try { + const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { + maxBuffer: 1024 * 1024 + }); + const value = stdout.trim(); + return value.length > 0 ? value : undefined; + } catch { + return undefined; + } +} + +async function canAccess(targetPath: string): Promise { + try { + await access(targetPath, fsConstants.R_OK); + return true; + } catch { + return false; + } +} + +async function collectRepoFiles(root: string, maxFiles: number): Promise { + const files: string[] = []; + async function visit(relativeDir: string): Promise { + if (files.length >= maxFiles) { + return; + } + const absoluteDir = path.join(root, relativeDir); + let entries; + try { + entries = await readdir(absoluteDir, { withFileTypes: true, encoding: "utf8" }); + } catch { + return; + } + for (const entry of entries) { + if (files.length >= maxFiles) { + return; + } + if (entry.name.startsWith(".") && entry.name !== ".github") { + continue; + } + if (entry.name.startsWith("_workspace")) { + continue; + } + const relativePath = path.join(relativeDir, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRECTORIES.has(entry.name)) { + await visit(relativePath); + } + } else if (entry.isFile()) { + files.push(relativePath); + } + } + } + + await visit(""); + return files.sort(); +} + +function isDocFile(file: string): boolean { + const normalized = file.toLowerCase(); + return normalized === "readme.md" || normalized === "changelog.md" || normalized.startsWith("docs/"); +} + +async function scanTodoMarkers(root: string, files: string[]): Promise<{ count: number; files: string[] }> { + let count = 0; + const todoFiles: string[] = []; + for (const file of files) { + if (!isTextFile(file)) { + continue; + } + const absolutePath = path.join(root, file); + let fileStat: Awaited>; + try { + fileStat = await stat(absolutePath); + } catch { + continue; + } + if (fileStat.size > MAX_TEXT_BYTES) { + continue; + } + let content: string; + try { + content = await readFile(absolutePath, "utf8"); + } catch { + continue; + } + const lines = content.split(/\r?\n/u); + for (const [index, line] of lines.entries()) { + if (/\b(TODO|FIXME|XXX)\b/iu.test(line)) { + count += 1; + todoFiles.push(`${file}:${index + 1}`); + } + } + } + return { + count, + files: todoFiles.slice(0, 50) + }; +} + +function isTextFile(file: string): boolean { + return TEXT_EXTENSIONS.has(path.extname(file).toLowerCase()); +} diff --git a/packages/runtime/src/runtime.test.ts b/packages/runtime/src/runtime.test.ts index b4f9fd8..5f05c3f 100644 --- a/packages/runtime/src/runtime.test.ts +++ b/packages/runtime/src/runtime.test.ts @@ -1,6 +1,8 @@ -import { mkdtemp, readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { explainWorkCueItem, @@ -11,6 +13,8 @@ import { writeWorkCueOutputs } from "./index.js"; +const execFileAsync = promisify(execFile); + describe("runWorkCueToday", () => { it("creates a deterministic demo brief", async () => { const result = await runWorkCueToday({ date: "2026-05-29", demo: true }); @@ -36,6 +40,29 @@ describe("syncWorkCueSources", () => { expect(result.sourceCounts.github).toBe(2); expect(result.syncedAt).toMatch(/T/); }); + + it("enriches work items with local project context without exposing local paths", async () => { + const repoPath = await createFixtureGitRepo(); + const result = await syncWorkCueSources({ + date: "2026-05-29", + demo: true, + projectPath: repoPath + }); + const item = result.items.find((candidate) => candidate.id === "github:pr-184"); + + expect(result.projectContexts).toHaveLength(1); + expect(result.projectContexts[0]?.repoName).toBe("app"); + expect(item?.projectContexts?.[0]).toMatchObject({ + projectId: "cli-project", + repoName: "app", + currentBranch: "feature/auth-cleanup", + isDirty: true + }); + expect(item?.projectContexts?.[0]?.signals).toEqual( + expect.arrayContaining(["active_branch", "dirty_worktree", "source_url"]) + ); + expect(JSON.stringify(result)).not.toContain(repoPath); + }); }); describe("explainWorkCueItem", () => { @@ -68,3 +95,22 @@ describe("writeWorkCueOutputs", () => { expect(written.markdownPath).toBe(outputPath); }); }); + +async function createFixtureGitRepo(): Promise { + const repoPath = await mkdtemp(path.join(os.tmpdir(), "workcue-project-")); + await mkdir(path.join(repoPath, "src")); + await writeFile(path.join(repoPath, "package.json"), '{"name":"app","scripts":{"test":"vitest run"}}\n', "utf8"); + await writeFile(path.join(repoPath, "README.md"), "# App\n", "utf8"); + await writeFile(path.join(repoPath, "src", "auth.ts"), "export const auth = true;\n", "utf8"); + await runGit(repoPath, ["init"]); + await runGit(repoPath, ["remote", "add", "origin", "https://github.com/acme/app.git"]); + await runGit(repoPath, ["add", "."]); + await runGit(repoPath, ["-c", "user.name=WorkCue", "-c", "user.email=workcue@example.com", "commit", "-m", "feat: auth baseline"]); + await runGit(repoPath, ["checkout", "-b", "feature/auth-cleanup"]); + await writeFile(path.join(repoPath, "src", "auth.ts"), "export const auth = true;\n// TODO: review PR #184 retry path\n", "utf8"); + return repoPath; +} + +async function runGit(cwd: string, args: string[]): Promise { + await execFileAsync("git", ["-C", cwd, ...args]); +} diff --git a/packages/runtime/src/runtime.ts b/packages/runtime/src/runtime.ts index 6a58588..176dfeb 100644 --- a/packages/runtime/src/runtime.ts +++ b/packages/runtime/src/runtime.ts @@ -14,6 +14,13 @@ import { } from "@workcue/core"; import { generateBriefSummary } from "@workcue/llm"; import { upsertDailyNoteSection, writeMarkdownFile } from "@workcue/output-markdown"; +import { + collectProjectContexts, + enrichWorkItemsWithProjectContexts, + serializeProjectContextSummary, + type ProjectConfig, + type ProjectContextSummary +} from "./project-context.js"; export type WorkCueRuntimeErrorCode = "ITEM_NOT_FOUND" | "NO_ITEMS_FOUND" | "NO_SOURCES_CONFIGURED"; export type WorkCueSourceCounts = Record; @@ -38,6 +45,8 @@ export interface RunWorkCueTodayOptions { notionBoard?: string; notionTokenEnv?: string; obsidianVault?: string; + projectPath?: string; + projectRemote?: string; timezone?: string; top?: number; } @@ -50,6 +59,7 @@ export type ExplainWorkCueItemOptions = RunWorkCueTodayOptions & { export interface WorkCueSyncResult { config?: WorkCueConfig; items: WorkItem[]; + projectContexts: ProjectContextSummary[]; sourceCounts: WorkCueSourceCounts; syncedAt: string; userHandles: string[]; @@ -60,6 +70,7 @@ export interface WorkCueTodayResult { config?: WorkCueConfig; items: WorkItem[]; markdown: string; + projectContexts: ProjectContextSummary[]; sourceCounts: WorkCueSourceCounts; userHandles: string[]; } @@ -92,6 +103,7 @@ export async function runWorkCueToday(options: RunWorkCueTodayOptions): Promise< brief, items: syncResult.items, markdown: renderBriefMarkdown(brief), + projectContexts: syncResult.projectContexts, sourceCounts: syncResult.sourceCounts, userHandles: syncResult.userHandles }; @@ -105,8 +117,9 @@ export async function syncWorkCueSources(options: SyncWorkCueSourcesOptions): Pr const config = options.config ?? (options.configPath ? await loadConfig(options.configPath) : undefined); const obsidianVault = options.obsidianVault ?? config?.sources.obsidian.vaultPath; const notionBoards = buildRuntimeNotionBoards(options, config); + const runtimeProjects = buildRuntimeProjects(options, config); const userHandles = buildUserHandles(options.assignee ?? "you", config); - const items: WorkItem[] = []; + let items: WorkItem[] = []; if (options.demo) { items.push(...buildDemoWorkItems(options.date)); @@ -139,8 +152,15 @@ export async function syncWorkCueSources(options: SyncWorkCueSourcesOptions): Pr throw new WorkCueRuntimeError("NO_ITEMS_FOUND", "No open work items found."); } + const projectContexts = + runtimeProjects.length > 0 ? await collectProjectContexts({ projects: runtimeProjects }) : []; + if (projectContexts.length > 0) { + items = enrichWorkItemsWithProjectContexts(items, projectContexts); + } + const result: WorkCueSyncResult = { items, + projectContexts, sourceCounts: countSources(items), syncedAt: new Date().toISOString(), userHandles @@ -199,9 +219,18 @@ export function renderRecommendationExplanation(recommendation: Recommendation): recommendation.suggestedAction ]; + const projectContextLines = renderProjectContextExplanation(recommendation.workItem.projectContexts); + if (projectContextLines.length > 0) { + lines.push("", "## Project context", "", ...projectContextLines); + } + return `${lines.join("\n").trim()}\n`; } +export function serializeProjectContext(context: ProjectContextSummary): Record { + return serializeProjectContextSummary(context); +} + export async function writeWorkCueOutputs(options: WriteWorkCueOutputsOptions): Promise { const written: WorkCueWrittenOutputs = {}; const markdownPath = options.outputPath ?? expandDateTemplate(options.config?.outputs.markdown.path, options.date); @@ -295,6 +324,33 @@ function buildObsidianSyncOptions( return options; } +function buildRuntimeProjects( + options: Pick, + config: WorkCueConfig | undefined +): ProjectConfig[] { + const projects = [...(config?.projects ?? [])]; + if (options.projectPath || options.projectRemote) { + const project: ProjectConfig = { + id: "cli-project", + name: "CLI project", + repo: {}, + match: { + keywords: [], + labels: [], + sourceUrls: [] + } + }; + if (options.projectPath) { + project.repo.localPath = options.projectPath; + } + if (options.projectRemote) { + project.repo.remoteUrl = options.projectRemote; + } + projects.push(project); + } + return projects; +} + function shouldUseGitHub(config: WorkCueConfig | undefined): config is WorkCueConfig { return Boolean( config?.sources.github.enabled && @@ -421,3 +477,18 @@ function countSources(items: WorkItem[]): WorkCueSourceCounts { return counts; }, {}); } + +function renderProjectContextExplanation( + contexts: WorkItem["projectContexts"] | undefined +): string[] { + if (!contexts || contexts.length === 0) { + return []; + } + return contexts.map((context) => { + const repo = context.repoName ?? context.projectName ?? context.projectId; + const branch = context.currentBranch ? `, branch ${context.currentBranch}` : ""; + const dirty = context.isDirty ? ", dirty worktree" : ""; + const files = context.matchedFiles.length > 0 ? `, matched files ${context.matchedFiles.slice(0, 3).join(", ")}` : ""; + return `- ${repo}${branch}${dirty}${files}`; + }); +}