@fureworks/scope 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -0
  3. package/dist/cli/config.d.ts +2 -0
  4. package/dist/cli/config.d.ts.map +1 -0
  5. package/dist/cli/config.js +54 -0
  6. package/dist/cli/config.js.map +1 -0
  7. package/dist/cli/context.d.ts +6 -0
  8. package/dist/cli/context.d.ts.map +1 -0
  9. package/dist/cli/context.js +76 -0
  10. package/dist/cli/context.js.map +1 -0
  11. package/dist/cli/daemon.d.ts +2 -0
  12. package/dist/cli/daemon.d.ts.map +1 -0
  13. package/dist/cli/daemon.js +190 -0
  14. package/dist/cli/daemon.js.map +1 -0
  15. package/dist/cli/onboard.d.ts +2 -0
  16. package/dist/cli/onboard.d.ts.map +1 -0
  17. package/dist/cli/onboard.js +286 -0
  18. package/dist/cli/onboard.js.map +1 -0
  19. package/dist/cli/status.d.ts +6 -0
  20. package/dist/cli/status.d.ts.map +1 -0
  21. package/dist/cli/status.js +57 -0
  22. package/dist/cli/status.js.map +1 -0
  23. package/dist/cli/switch.d.ts +2 -0
  24. package/dist/cli/switch.d.ts.map +1 -0
  25. package/dist/cli/switch.js +78 -0
  26. package/dist/cli/switch.js.map +1 -0
  27. package/dist/cli/today.d.ts +7 -0
  28. package/dist/cli/today.d.ts.map +1 -0
  29. package/dist/cli/today.js +80 -0
  30. package/dist/cli/today.js.map +1 -0
  31. package/dist/engine/prioritize.d.ts +21 -0
  32. package/dist/engine/prioritize.d.ts.map +1 -0
  33. package/dist/engine/prioritize.js +204 -0
  34. package/dist/engine/prioritize.js.map +1 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +48 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/notifications/index.d.ts +2 -0
  40. package/dist/notifications/index.d.ts.map +1 -0
  41. package/dist/notifications/index.js +41 -0
  42. package/dist/notifications/index.js.map +1 -0
  43. package/dist/sources/calendar.d.ts +17 -0
  44. package/dist/sources/calendar.d.ts.map +1 -0
  45. package/dist/sources/calendar.js +120 -0
  46. package/dist/sources/calendar.js.map +1 -0
  47. package/dist/sources/git.d.ts +20 -0
  48. package/dist/sources/git.d.ts.map +1 -0
  49. package/dist/sources/git.js +124 -0
  50. package/dist/sources/git.js.map +1 -0
  51. package/dist/sources/issues.d.ts +14 -0
  52. package/dist/sources/issues.d.ts.map +1 -0
  53. package/dist/sources/issues.js +34 -0
  54. package/dist/sources/issues.js.map +1 -0
  55. package/dist/store/config.d.ts +22 -0
  56. package/dist/store/config.d.ts.map +1 -0
  57. package/dist/store/config.js +74 -0
  58. package/dist/store/config.js.map +1 -0
  59. package/package.json +45 -0
  60. package/src/cli/config.ts +66 -0
  61. package/src/cli/context.ts +109 -0
  62. package/src/cli/daemon.ts +217 -0
  63. package/src/cli/onboard.ts +335 -0
  64. package/src/cli/status.ts +77 -0
  65. package/src/cli/switch.ts +93 -0
  66. package/src/cli/today.ts +114 -0
  67. package/src/engine/prioritize.ts +257 -0
  68. package/src/index.ts +58 -0
  69. package/src/notifications/index.ts +42 -0
  70. package/src/sources/calendar.ts +170 -0
  71. package/src/sources/git.ts +168 -0
  72. package/src/sources/issues.ts +62 -0
  73. package/src/store/config.ts +104 -0
  74. package/tsconfig.json +19 -0
@@ -0,0 +1,14 @@
1
+ export interface IssueSignal {
2
+ number: number;
3
+ title: string;
4
+ url: string;
5
+ repo: string;
6
+ ageDays: number;
7
+ labels: string[];
8
+ }
9
+ export interface IssueScanResult {
10
+ available: boolean;
11
+ issues: IssueSignal[];
12
+ }
13
+ export declare function scanAssignedIssues(): Promise<IssueScanResult>;
14
+ //# sourceMappingURL=issues.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"issues.d.ts","sourceRoot":"","sources":["../../src/sources/issues.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,WAAW,EAAE,CAAC;CACvB;AAED,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,eAAe,CAAC,CA+CnE"}
@@ -0,0 +1,34 @@
1
+ export async function scanAssignedIssues() {
2
+ try {
3
+ const { execSync } = await import("node:child_process");
4
+ try {
5
+ execSync("which gh", { stdio: "pipe" });
6
+ }
7
+ catch {
8
+ return { available: false, issues: [] };
9
+ }
10
+ const result = execSync("gh issue list --assignee @me --state open --json number,title,url,createdAt,labels,repository --limit 20", {
11
+ encoding: "utf-8",
12
+ timeout: 10000,
13
+ stdio: ["pipe", "pipe", "pipe"],
14
+ });
15
+ const parsed = JSON.parse(result);
16
+ const issues = parsed.map((issue) => {
17
+ const ageDays = (Date.now() - new Date(issue.createdAt).getTime()) /
18
+ (1000 * 60 * 60 * 24);
19
+ return {
20
+ number: issue.number,
21
+ title: issue.title,
22
+ url: issue.url,
23
+ repo: issue.repository?.nameWithOwner || issue.repository?.name || "unknown",
24
+ ageDays: Math.round(ageDays * 10) / 10,
25
+ labels: (issue.labels ?? []).map((label) => label.name || "").filter(Boolean),
26
+ };
27
+ });
28
+ return { available: true, issues };
29
+ }
30
+ catch {
31
+ return { available: true, issues: [] };
32
+ }
33
+ }
34
+ //# sourceMappingURL=issues.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"issues.js","sourceRoot":"","sources":["../../src/sources/issues.ts"],"names":[],"mappings":"AAcA,MAAM,CAAC,KAAK,UAAU,kBAAkB;IACtC,IAAI,CAAC;QACH,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,MAAM,CAAC,oBAAoB,CAAC,CAAC;QAExD,IAAI,CAAC;YACH,QAAQ,CAAC,UAAU,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;QAC1C,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;QAC1C,CAAC;QAED,MAAM,MAAM,GAAG,QAAQ,CACrB,0GAA0G,EAC1G;YACE,QAAQ,EAAE,OAAO;YACjB,OAAO,EAAE,KAAK;YACd,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;SAChC,CACF,CAAC;QAEF,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAO9B,CAAC;QAEH,MAAM,MAAM,GAAkB,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;YACjD,MAAM,OAAO,GACX,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,CAAC;gBAClD,CAAC,IAAI,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC;YAExB,OAAO;gBACL,MAAM,EAAE,KAAK,CAAC,MAAM;gBACpB,KAAK,EAAE,KAAK,CAAC,KAAK;gBAClB,GAAG,EAAE,KAAK,CAAC,GAAG;gBACd,IAAI,EAAE,KAAK,CAAC,UAAU,EAAE,aAAa,IAAI,KAAK,CAAC,UAAU,EAAE,IAAI,IAAI,SAAS;gBAC5E,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAC,GAAG,EAAE;gBACtC,MAAM,EAAE,CAAC,KAAK,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC;aAC9E,CAAC;QACJ,CAAC,CAAC,CAAC;QAEH,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IACrC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;IACzC,CAAC;AACH,CAAC"}
@@ -0,0 +1,22 @@
1
+ export interface ScopeConfig {
2
+ repos: string[];
3
+ projects: Record<string, {
4
+ path: string;
5
+ repos?: string[];
6
+ description?: string;
7
+ }>;
8
+ calendar: {
9
+ enabled: boolean;
10
+ backend: "gws";
11
+ };
12
+ daemon: {
13
+ enabled: boolean;
14
+ intervalMinutes: number;
15
+ };
16
+ }
17
+ export declare function getScopeDir(): string;
18
+ export declare function ensureScopeDir(): void;
19
+ export declare function configExists(): boolean;
20
+ export declare function loadConfig(): ScopeConfig;
21
+ export declare function saveConfig(config: ScopeConfig): void;
22
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/store/config.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,QAAQ,EAAE,MAAM,CACd,MAAM,EACN;QACE,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CACF,CAAC;IACF,QAAQ,EAAE;QACR,OAAO,EAAE,OAAO,CAAC;QACjB,OAAO,EAAE,KAAK,CAAC;KAChB,CAAC;IACF,MAAM,EAAE;QACN,OAAO,EAAE,OAAO,CAAC;QACjB,eAAe,EAAE,MAAM,CAAC;KACzB,CAAC;CACH;AAKD,wBAAgB,WAAW,IAAI,MAAM,CAEpC;AAED,wBAAgB,cAAc,IAAI,IAAI,CAQrC;AAED,wBAAgB,YAAY,IAAI,OAAO,CAEtC;AAED,wBAAgB,UAAU,IAAI,WAAW,CAyBxC;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,CA8BpD"}
@@ -0,0 +1,74 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { parse as parseToml } from "toml";
5
+ const SCOPE_DIR = join(homedir(), ".scope");
6
+ const CONFIG_PATH = join(SCOPE_DIR, "config.toml");
7
+ export function getScopeDir() {
8
+ return SCOPE_DIR;
9
+ }
10
+ export function ensureScopeDir() {
11
+ if (!existsSync(SCOPE_DIR)) {
12
+ mkdirSync(SCOPE_DIR, { recursive: true });
13
+ }
14
+ const contextsDir = join(SCOPE_DIR, "contexts");
15
+ if (!existsSync(contextsDir)) {
16
+ mkdirSync(contextsDir, { recursive: true });
17
+ }
18
+ }
19
+ export function configExists() {
20
+ return existsSync(CONFIG_PATH);
21
+ }
22
+ export function loadConfig() {
23
+ if (!configExists()) {
24
+ return {
25
+ repos: [],
26
+ projects: {},
27
+ calendar: { enabled: false, backend: "gws" },
28
+ daemon: { enabled: false, intervalMinutes: 15 },
29
+ };
30
+ }
31
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
32
+ const parsed = parseToml(raw);
33
+ return {
34
+ repos: parsed.repos ?? [],
35
+ projects: parsed.projects ?? {},
36
+ calendar: {
37
+ enabled: parsed.calendar?.enabled ?? false,
38
+ backend: parsed.calendar?.backend ?? "gws",
39
+ },
40
+ daemon: {
41
+ enabled: parsed.daemon?.enabled ?? false,
42
+ intervalMinutes: parsed.daemon?.intervalMinutes ?? 15,
43
+ },
44
+ };
45
+ }
46
+ export function saveConfig(config) {
47
+ ensureScopeDir();
48
+ const lines = [];
49
+ lines.push("# Scope configuration");
50
+ lines.push("");
51
+ lines.push(`repos = [${config.repos.map((r) => `"${r}"`).join(", ")}]`);
52
+ lines.push("");
53
+ lines.push("[calendar]");
54
+ lines.push(`enabled = ${config.calendar.enabled}`);
55
+ lines.push(`backend = "${config.calendar.backend}"`);
56
+ lines.push("");
57
+ lines.push("[daemon]");
58
+ lines.push(`enabled = ${config.daemon.enabled}`);
59
+ lines.push(`intervalMinutes = ${config.daemon.intervalMinutes}`);
60
+ lines.push("");
61
+ for (const [name, project] of Object.entries(config.projects)) {
62
+ lines.push(`[projects.${name}]`);
63
+ lines.push(`path = "${project.path}"`);
64
+ if (project.repos && project.repos.length > 0) {
65
+ lines.push(`repos = [${project.repos.map((r) => `"${r}"`).join(", ")}]`);
66
+ }
67
+ if (project.description) {
68
+ lines.push(`description = "${project.description}"`);
69
+ }
70
+ lines.push("");
71
+ }
72
+ writeFileSync(CONFIG_PATH, lines.join("\n"), "utf-8");
73
+ }
74
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/store/config.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@fureworks/scope",
3
+ "version": "0.1.0",
4
+ "description": "Personal ops CLI — focus on what matters",
5
+ "type": "module",
6
+ "bin": {
7
+ "scope": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsx src/index.ts",
12
+ "lint": "eslint src/",
13
+ "start": "node dist/index.js"
14
+ },
15
+ "keywords": [
16
+ "cli",
17
+ "productivity",
18
+ "ops",
19
+ "focus",
20
+ "prioritization",
21
+ "git",
22
+ "calendar"
23
+ ],
24
+ "author": "Fureworks <info@fureworks.com>",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/fureworks/scope.git"
29
+ },
30
+ "homepage": "https://fureworks.com",
31
+ "engines": {
32
+ "node": ">=18.0.0"
33
+ },
34
+ "dependencies": {
35
+ "chalk": "^5.4.0",
36
+ "commander": "^13.1.0",
37
+ "simple-git": "^3.27.0",
38
+ "toml": "^3.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.0.0",
42
+ "tsx": "^4.19.0",
43
+ "typescript": "^5.7.0"
44
+ }
45
+ }
@@ -0,0 +1,66 @@
1
+ import chalk from "chalk";
2
+ import { loadConfig, configExists } from "../store/config.js";
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { homedir } from "node:os";
6
+
7
+ export async function configCommand(
8
+ key?: string,
9
+ value?: string
10
+ ): Promise<void> {
11
+ if (!configExists()) {
12
+ console.log(
13
+ chalk.yellow("\n No config found. Run `scope onboard` to get started.\n")
14
+ );
15
+ return;
16
+ }
17
+
18
+ // If no args, show config file contents
19
+ if (!key) {
20
+ const configPath = join(homedir(), ".scope", "config.toml");
21
+ try {
22
+ const content = readFileSync(configPath, "utf-8");
23
+ console.log("");
24
+ console.log(chalk.bold(" ~/.scope/config.toml"));
25
+ console.log(chalk.dim(" ─────────────────────\n"));
26
+ console.log(
27
+ content
28
+ .split("\n")
29
+ .map((line) => ` ${line}`)
30
+ .join("\n")
31
+ );
32
+ console.log("");
33
+ } catch {
34
+ console.log(chalk.yellow("\n Could not read config file.\n"));
35
+ }
36
+ return;
37
+ }
38
+
39
+ // Subcommands
40
+ switch (key) {
41
+ case "git":
42
+ console.log(chalk.dim("\n To manage repos, edit ~/.scope/config.toml"));
43
+ console.log(chalk.dim(" or re-run: scope onboard\n"));
44
+ break;
45
+ case "calendar":
46
+ console.log(
47
+ chalk.dim("\n To set up calendar, install gws:")
48
+ );
49
+ console.log(
50
+ chalk.dim(" npm install -g @googleworkspace/cli")
51
+ );
52
+ console.log(chalk.dim(" Then re-run: scope onboard\n"));
53
+ break;
54
+ case "projects":
55
+ const config = loadConfig();
56
+ console.log(chalk.bold("\n Projects:"));
57
+ for (const [name, project] of Object.entries(config.projects)) {
58
+ console.log(` ${name} → ${project.path}`);
59
+ }
60
+ console.log(chalk.dim("\n Edit: ~/.scope/config.toml\n"));
61
+ break;
62
+ default:
63
+ console.log(chalk.yellow(`\n Unknown config key: ${key}`));
64
+ console.log(chalk.dim(" Available: git, calendar, projects\n"));
65
+ }
66
+ }
@@ -0,0 +1,109 @@
1
+ import chalk from "chalk";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getScopeDir } from "../store/config.js";
5
+
6
+ interface ContextOptions {
7
+ edit?: boolean;
8
+ }
9
+
10
+ export async function contextCommand(options: ContextOptions): Promise<void> {
11
+ const contextsDir = join(getScopeDir(), "contexts");
12
+
13
+ if (!existsSync(contextsDir)) {
14
+ console.log(
15
+ chalk.yellow("\n No project contexts yet. Run `scope switch <project>` first.\n")
16
+ );
17
+ return;
18
+ }
19
+
20
+ // Find the most recently switched context
21
+ const files = readdirSync(contextsDir).filter((f) => f.endsWith(".json"));
22
+
23
+ if (files.length === 0) {
24
+ console.log(
25
+ chalk.yellow("\n No project contexts yet. Run `scope switch <project>` first.\n")
26
+ );
27
+ return;
28
+ }
29
+
30
+ let latest: { name: string; data: Record<string, unknown>; time: number } | null = null;
31
+
32
+ for (const file of files) {
33
+ try {
34
+ const data = JSON.parse(
35
+ readFileSync(join(contextsDir, file), "utf-8")
36
+ );
37
+ const time = new Date(data.lastSwitchedAt || 0).getTime();
38
+ if (!latest || time > latest.time) {
39
+ latest = { name: data.name, data, time };
40
+ }
41
+ } catch {
42
+ // Skip corrupt files
43
+ }
44
+ }
45
+
46
+ if (!latest) {
47
+ console.log(
48
+ chalk.yellow("\n No valid contexts found.\n")
49
+ );
50
+ return;
51
+ }
52
+
53
+ const ctx = latest.data as {
54
+ name: string;
55
+ path: string;
56
+ branch: string;
57
+ lastSwitchedAt: string;
58
+ notes: string;
59
+ };
60
+
61
+ if (options.edit) {
62
+ const editor = process.env.EDITOR || "vi";
63
+ const { execSync } = await import("node:child_process");
64
+ const notesPath = join(contextsDir, `${ctx.name}.md`);
65
+
66
+ // Create notes file if it doesn't exist
67
+ if (!existsSync(notesPath)) {
68
+ const { writeFileSync } = await import("node:fs");
69
+ writeFileSync(notesPath, `# ${ctx.name}\n\n`, "utf-8");
70
+ }
71
+
72
+ execSync(`${editor} ${notesPath}`, { stdio: "inherit" });
73
+ return;
74
+ }
75
+
76
+ console.log("");
77
+ console.log(chalk.bold(` Current: ${ctx.name}`));
78
+ console.log(chalk.dim(` ─────────────────────`));
79
+ console.log(` 📁 ${ctx.path}`);
80
+ console.log(` 🌿 ${ctx.branch}`);
81
+
82
+ if (ctx.lastSwitchedAt) {
83
+ const ago = Math.round(
84
+ (Date.now() - new Date(ctx.lastSwitchedAt).getTime()) / (1000 * 60 * 60)
85
+ );
86
+ if (ago < 1) {
87
+ console.log(chalk.dim(` Switched: just now`));
88
+ } else {
89
+ console.log(chalk.dim(` Switched: ${ago}h ago`));
90
+ }
91
+ }
92
+
93
+ if (ctx.notes) {
94
+ console.log(`\n 📝 ${ctx.notes}`);
95
+ }
96
+
97
+ // Show other projects
98
+ if (files.length > 1) {
99
+ console.log(chalk.dim(`\n Other projects:`));
100
+ for (const file of files) {
101
+ const name = file.replace(".json", "");
102
+ if (name !== ctx.name) {
103
+ console.log(chalk.dim(` scope switch ${name}`));
104
+ }
105
+ }
106
+ }
107
+
108
+ console.log("");
109
+ }
@@ -0,0 +1,217 @@
1
+ import chalk from "chalk";
2
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { spawn } from "node:child_process";
4
+ import { join } from "node:path";
5
+ import { configExists, ensureScopeDir, getScopeDir, loadConfig, saveConfig } from "../store/config.js";
6
+ import { scanAllRepos } from "../sources/git.js";
7
+ import { getCalendarToday } from "../sources/calendar.js";
8
+ import { scanAssignedIssues } from "../sources/issues.js";
9
+ import { prioritize } from "../engine/prioritize.js";
10
+ import { notify } from "../notifications/index.js";
11
+
12
+ const PID_PATH = join(getScopeDir(), "daemon.pid");
13
+ const NOTIFIED_PATH = join(getScopeDir(), "notified.json");
14
+ const DEBOUNCE_MS = 60 * 60 * 1000;
15
+
16
+ type NotifiedState = Record<string, string>;
17
+
18
+ function isProcessRunning(pid: number): boolean {
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ function readPid(): number | null {
28
+ if (!existsSync(PID_PATH)) return null;
29
+ const raw = readFileSync(PID_PATH, "utf-8").trim();
30
+ const pid = Number.parseInt(raw, 10);
31
+ return Number.isFinite(pid) ? pid : null;
32
+ }
33
+
34
+ function removePidFile(): void {
35
+ if (existsSync(PID_PATH)) {
36
+ unlinkSync(PID_PATH);
37
+ }
38
+ }
39
+
40
+ function loadNotifiedState(): NotifiedState {
41
+ if (!existsSync(NOTIFIED_PATH)) return {};
42
+ try {
43
+ return JSON.parse(readFileSync(NOTIFIED_PATH, "utf-8")) as NotifiedState;
44
+ } catch {
45
+ return {};
46
+ }
47
+ }
48
+
49
+ function saveNotifiedState(state: NotifiedState): void {
50
+ writeFileSync(NOTIFIED_PATH, JSON.stringify(state, null, 2), "utf-8");
51
+ }
52
+
53
+ async function runSignalCheck(): Promise<void> {
54
+ if (!configExists()) return;
55
+
56
+ const config = loadConfig();
57
+ const gitSignals = await scanAllRepos(config.repos);
58
+
59
+ let calendarEvents: Awaited<ReturnType<typeof getCalendarToday>> = null;
60
+ if (config.calendar.enabled) {
61
+ calendarEvents = await getCalendarToday();
62
+ }
63
+
64
+ const issueScan = await scanAssignedIssues();
65
+ const events = calendarEvents?.events ?? [];
66
+ const freeBlocks = calendarEvents?.freeBlocks ?? [];
67
+ const result = prioritize(gitSignals, events, freeBlocks, issueScan.issues);
68
+
69
+ const candidates = [...result.now, ...result.today].filter((item) => item.score >= 8);
70
+ if (candidates.length === 0) return;
71
+
72
+ ensureScopeDir();
73
+ const state = loadNotifiedState();
74
+ const nowMs = Date.now();
75
+ let changed = false;
76
+
77
+ for (const item of candidates) {
78
+ const id = `${item.source}:${item.label}`;
79
+ const lastNotified = state[id] ? new Date(state[id]).getTime() : 0;
80
+ if (lastNotified && nowMs - lastNotified < DEBOUNCE_MS) {
81
+ continue;
82
+ }
83
+
84
+ notify("Scope: Action Needed", `${item.emoji} ${item.label} — ${item.detail}`);
85
+ state[id] = new Date(nowMs).toISOString();
86
+ changed = true;
87
+ }
88
+
89
+ if (changed) {
90
+ saveNotifiedState(state);
91
+ }
92
+ }
93
+
94
+ function markDaemonEnabled(enabled: boolean): void {
95
+ if (!configExists()) return;
96
+ const config = loadConfig();
97
+ config.daemon.enabled = enabled;
98
+ saveConfig(config);
99
+ }
100
+
101
+ export async function daemonCommand(action: string): Promise<void> {
102
+ switch (action) {
103
+ case "start":
104
+ await startDaemon();
105
+ return;
106
+ case "stop":
107
+ stopDaemon();
108
+ return;
109
+ case "status":
110
+ showDaemonStatus();
111
+ return;
112
+ case "run":
113
+ await runDaemonLoop();
114
+ return;
115
+ default:
116
+ console.log(chalk.yellow(`\n Unknown daemon action: ${action}`));
117
+ console.log(chalk.dim(" Use: scope daemon start|stop|status\n"));
118
+ }
119
+ }
120
+
121
+ async function startDaemon(): Promise<void> {
122
+ if (!configExists()) {
123
+ console.log(chalk.yellow("\n Scope isn't set up yet. Run `scope onboard` first.\n"));
124
+ return;
125
+ }
126
+
127
+ ensureScopeDir();
128
+
129
+ const existingPid = readPid();
130
+ if (existingPid && isProcessRunning(existingPid)) {
131
+ console.log(chalk.green(`\n Daemon already running (PID ${existingPid}).\n`));
132
+ return;
133
+ }
134
+ removePidFile();
135
+
136
+ const child = spawn(process.execPath, [process.argv[1], "daemon", "run"], {
137
+ detached: true,
138
+ stdio: "ignore",
139
+ });
140
+ child.unref();
141
+
142
+ writeFileSync(PID_PATH, String(child.pid), "utf-8");
143
+ markDaemonEnabled(true);
144
+
145
+ console.log(chalk.green(`\n Daemon started (PID ${child.pid}).\n`));
146
+ }
147
+
148
+ function stopDaemon(): void {
149
+ const pid = readPid();
150
+ if (!pid) {
151
+ console.log(chalk.dim("\n Daemon is not running.\n"));
152
+ markDaemonEnabled(false);
153
+ return;
154
+ }
155
+
156
+ if (!isProcessRunning(pid)) {
157
+ removePidFile();
158
+ console.log(chalk.dim("\n Daemon was not running (stale PID removed).\n"));
159
+ markDaemonEnabled(false);
160
+ return;
161
+ }
162
+
163
+ try {
164
+ process.kill(pid);
165
+ removePidFile();
166
+ markDaemonEnabled(false);
167
+ console.log(chalk.green(`\n Daemon stopped (PID ${pid}).\n`));
168
+ } catch {
169
+ console.log(chalk.yellow(`\n Could not stop daemon PID ${pid}.\n`));
170
+ }
171
+ }
172
+
173
+ function showDaemonStatus(): void {
174
+ const config = loadConfig();
175
+ const pid = readPid();
176
+
177
+ if (pid && isProcessRunning(pid)) {
178
+ console.log(chalk.green(`\n Daemon running (PID ${pid}).`));
179
+ console.log(chalk.dim(` Interval: ${config.daemon.intervalMinutes} minutes\n`));
180
+ return;
181
+ }
182
+
183
+ if (pid) {
184
+ removePidFile();
185
+ }
186
+
187
+ console.log(chalk.dim("\n Daemon is not running."));
188
+ console.log(chalk.dim(` Interval: ${config.daemon.intervalMinutes} minutes\n`));
189
+ }
190
+
191
+ async function runDaemonLoop(): Promise<void> {
192
+ ensureScopeDir();
193
+ writeFileSync(PID_PATH, String(process.pid), "utf-8");
194
+
195
+ const cleanup = () => {
196
+ const pid = readPid();
197
+ if (pid === process.pid) {
198
+ removePidFile();
199
+ }
200
+ process.exit(0);
201
+ };
202
+
203
+ process.on("SIGTERM", cleanup);
204
+ process.on("SIGINT", cleanup);
205
+
206
+ while (true) {
207
+ try {
208
+ await runSignalCheck();
209
+ } catch {
210
+ // Keep daemon alive even if checks fail.
211
+ }
212
+
213
+ const config = configExists() ? loadConfig() : null;
214
+ const intervalMinutes = Math.max(1, config?.daemon.intervalMinutes ?? 15);
215
+ await new Promise((resolve) => setTimeout(resolve, intervalMinutes * 60 * 1000));
216
+ }
217
+ }