github-portfolio-analyzer 1.2.0 → 1.4.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.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,25 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.4.0] — 2026-04-03
8
+
9
+ ### Added
10
+ - Smoke tests for `--version` and `--help`
11
+ - Analyze command integration coverage for env-token execution and generated inventory fields
12
+ - `dormant` state in report summaries and documentation, while preserving manual `abandoned` compatibility
13
+
14
+ ### Changed
15
+ - Automatic inactivity classification now returns `dormant` instead of `abandoned`
16
+ - Repository taxonomy inference now prioritizes `experiment` before `library`, broadens `learning` signals, and classifies simple apps like clocks/calculators/games as `product`
17
+ - Fork fallback classification now uses recent fork activity when upstream compare metadata is unavailable
18
+
19
+ ## [1.3.0] — 2026-04-03
20
+
7
21
  ### Added
22
+ - `forkType` classification for forks via the GitHub compare API, distinguishing `active` forks from `passive` clones
23
+ - `publicAlias` best-effort generation for private repositories with OpenAI → Gemini → Anthropic fallback
24
+ - Global CLI credential flags: `--github-token`, `--github-username`, `--openai-key`, `--gemini-key`, `--anthropic-key`
25
+ - Interactive prompting for missing GitHub and optional LLM keys when `analyze` runs on a TTY
8
26
  - Colored terminal output — progress, success, warning, and error states with ANSI colors
9
27
  - Terminal header with ASCII art, version info, user, token status, and policy status
10
28
  - Per-repository progress logging during `analyze` (Analyzing N/total: repo-name)
package/README.md CHANGED
@@ -495,7 +495,8 @@ Each `portfolio.json.items[]` entry includes:
495
495
 
496
496
  - `type`: `repo | idea`
497
497
  - `category`: `product | tooling | library | learning | content | infra | experiment | template`
498
- - `state`: `idea | active | stale | abandoned | archived | reference-only`
498
+ - `state`: `idea | active | stale | dormant | abandoned | archived | reference-only`
499
+ - Auto-classified repository inactivity uses `dormant`; `abandoned` remains supported for manual curation.
499
500
  - `strategy`: `strategic-core | strategic-support | opportunistic | maintenance | parked`
500
501
  - `effort`: `xs | s | m | l | xl`
501
502
  - `value`: `low | medium | high | very-high`
@@ -521,11 +522,11 @@ Every repository passes through a deterministic scoring pipeline:
521
522
  flowchart LR
522
523
  subgraph top [ ]
523
524
  direction LR
524
- A([repo metadata]) --> B(inferRepoCategory) --> C([category]) --> D(scoreRepository) --> E([score 0100])
525
+ A([repo metadata]) --> B(inferRepoCategory) --> C([category]) --> D(scoreRepository) --> E([score 0-100])
525
526
  end
526
527
  subgraph mid [ ]
527
528
  direction RL
528
- J(computePriorityBand) <-- I([effort xsxl]) <-- H(computeEffortEstimate) <-- G([CL 05]) <-- F(computeCompletionLevel)
529
+ J(computePriorityBand) <-- I([effort xs-xl]) <-- H(computeEffortEstimate) <-- G([CL 0-5]) <-- F(computeCompletionLevel)
529
530
  end
530
531
  E --> F
531
532
  E -. feeds .-> J
@@ -606,7 +607,7 @@ final `priorityScore`, which determines the band.
606
607
  |---|---|---|
607
608
  | State boost | `active` | +10 |
608
609
  | State boost | `stale` | +5 |
609
- | State penalty | `abandoned` or `archived` | −20 |
610
+ | State penalty | `dormant`, `abandoned`, or `archived` | −20 |
610
611
  | Quick-win boost | CL 1, 2, or 3 | +10 |
611
612
  | Effort penalty | `l` or `xl` | −10 |
612
613
 
@@ -614,7 +615,7 @@ final `priorityScore`, which determines the band.
614
615
 
615
616
  | Band | Range | Meaning |
616
617
  |---|---|---|
617
- | `park` | < 45 | Needs a decision before any investment. Abandoned, low signal, or intentionally paused. |
618
+ | `park` | < 45 | Needs a decision before any investment. Dormant, low signal, or intentionally paused. |
618
619
  | `later` | 45–64 | Viable but not urgent. Can return when backlog has room. |
619
620
  | `next` | 65–79 | Strong candidate. High score but large effort, or active with average score. |
620
621
  | `now` | ≥ 80 | High confidence. Active project, good score, low effort — or manually pinned. |
@@ -633,7 +634,7 @@ updated 200d ago +0 (> 180 days)
633
634
  ──────────────────────────────────
634
635
  score 25
635
636
 
636
- state=abandoned −20
637
+ state=dormant −20
637
638
  effort=xl −10
638
639
  ──────────────────────────────────
639
640
  priorityScore −5 → park
@@ -1,11 +1,21 @@
1
1
  {
2
2
  "name": "github-portfolio-analyzer",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "commands": [
5
5
  { "id": "analyze" },
6
6
  { "id": "ingest-ideas" },
7
7
  { "id": "build-portfolio" },
8
- { "id": "report", "flags": ["--policy", "--explain", "--output", "--format", "--quiet"] }
8
+ {
9
+ "id": "report",
10
+ "flags": [
11
+ "--policy",
12
+ "--explain",
13
+ "--output",
14
+ "--format",
15
+ "--quiet",
16
+ "--presentation-overrides"
17
+ ]
18
+ }
9
19
  ],
10
20
  "outputs": [
11
21
  "output/inventory.json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-portfolio-analyzer",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "CLI tool to analyze GitHub repos and portfolio ideas",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,10 +51,11 @@
51
51
  "byState": {
52
52
  "type": "object",
53
53
  "additionalProperties": true,
54
- "required": ["active", "stale", "abandoned", "archived", "idea", "reference-only"],
54
+ "required": ["active", "stale", "dormant", "abandoned", "archived", "idea", "reference-only"],
55
55
  "properties": {
56
56
  "active": { "type": "number" },
57
57
  "stale": { "type": "number" },
58
+ "dormant": { "type": "number" },
58
59
  "abandoned": { "type": "number" },
59
60
  "archived": { "type": "number" },
60
61
  "idea": { "type": "number" },
@@ -252,6 +253,13 @@
252
253
  "type": "string",
253
254
  "enum": ["product", "tooling", "library", "learning", "content", "infra", "experiment", "template"]
254
255
  },
256
+ "fork": { "type": "boolean" },
257
+ "forkType": {
258
+ "type": "string",
259
+ "enum": ["active", "passive"]
260
+ },
261
+ "private": { "type": "boolean" },
262
+ "publicAlias": { "type": "string" },
255
263
  "presentationState": {
256
264
  "type": "string",
257
265
  "enum": ["featured", "complete", "in-progress", "salvageable", "learning", "archived", "hidden"]
package/src/cli.js CHANGED
@@ -6,23 +6,34 @@ import { parseArgs } from './utils/args.js';
6
6
  import packageJson from '../package.json' with { type: 'json' };
7
7
  import { UsageError } from './errors.js';
8
8
 
9
- const GLOBAL_OPTIONS = new Set(['help', 'strict', 'version']);
9
+ const GLOBAL_OPTIONS = new Set([
10
+ 'help',
11
+ 'strict',
12
+ 'version',
13
+ 'github-token',
14
+ 'github-username',
15
+ 'openai-key',
16
+ 'gemini-key',
17
+ 'anthropic-key'
18
+ ]);
10
19
  const COMMAND_OPTIONS = {
11
20
  analyze: new Set(['as-of', 'output-dir']),
12
21
  'ingest-ideas': new Set(['input', 'prompt', 'output-dir']),
13
22
  'build-portfolio': new Set(['output-dir']),
14
- report: new Set(['output-dir', 'output', 'format', 'policy', 'priorities', 'explain', 'quiet'])
23
+ report: new Set(['output-dir', 'output', 'format', 'policy', 'priorities', 'explain', 'quiet', 'presentation-overrides'])
15
24
  };
16
25
 
17
26
  export async function runCli(argv) {
18
- const { positional, options } = parseArgs(argv);
27
+ const { positional, options: rawOptions } = parseArgs(argv);
19
28
  const [command] = positional;
20
- const strictMode = options.strict === true || options.strict === 'true';
29
+ const strictMode = rawOptions.strict === true || rawOptions.strict === 'true';
21
30
 
22
31
  if (strictMode) {
23
- validateStrictOptions(command, options);
32
+ validateStrictOptions(command, rawOptions);
24
33
  }
25
34
 
35
+ const options = mapCredentialOptions(rawOptions);
36
+
26
37
  if ((options.version === true && !command) || (command === '-v' && positional.length === 1)) {
27
38
  console.log(packageJson.version);
28
39
  return;
@@ -58,6 +69,11 @@ function printHelp() {
58
69
  console.log('github-portfolio-analyzer');
59
70
  console.log('Usage: github-portfolio-analyzer <command> [options]');
60
71
  console.log(' --strict Global: fail on unknown flags (exit code 2)');
72
+ console.log(' --github-token TOKEN Global: override GITHUB_TOKEN');
73
+ console.log(' --github-username USER Global: override GITHUB_USERNAME');
74
+ console.log(' --openai-key KEY Global: override OPENAI_API_KEY');
75
+ console.log(' --gemini-key KEY Global: override GEMINI_API_KEY');
76
+ console.log(' --anthropic-key KEY Global: override ANTHROPIC_API_KEY');
61
77
  console.log('Commands:');
62
78
  console.log(' analyze Analyze GitHub repositories and build inventory outputs');
63
79
  console.log(' ingest-ideas Add or update manual project ideas');
@@ -81,6 +97,8 @@ function printHelp() {
81
97
  console.log(' --format VALUE ascii|md|json|all (default: all)');
82
98
  console.log(' --policy PATH Optional policy overlay JSON file');
83
99
  console.log(' --priorities PATH Alias for --policy');
100
+ console.log(' --presentation-overrides PATH');
101
+ console.log(' Optional presentation overrides JSON file');
84
102
  console.log(' --explain Print NOW ranking explainability to console');
85
103
  console.log(' --quiet Suppress non-error logs');
86
104
  }
@@ -101,3 +119,14 @@ function validateStrictOptions(command, options) {
101
119
  throw new UsageError(`Unknown option(s): ${unknownFlags}`);
102
120
  }
103
121
  }
122
+
123
+ function mapCredentialOptions(options) {
124
+ return {
125
+ ...options,
126
+ ...(options['github-token'] !== undefined ? { githubToken: options['github-token'] } : {}),
127
+ ...(options['github-username'] !== undefined ? { githubUsername: options['github-username'] } : {}),
128
+ ...(options['openai-key'] !== undefined ? { openaiKey: options['openai-key'] } : {}),
129
+ ...(options['gemini-key'] !== undefined ? { geminiKey: options['gemini-key'] } : {}),
130
+ ...(options['anthropic-key'] !== undefined ? { anthropicKey: options['anthropic-key'] } : {})
131
+ };
132
+ }
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { getEnv, requireGithubToken } from '../config.js';
2
+ import { promptMissingKeys, requireGithubToken } from '../config.js';
3
3
  import { GithubClient } from '../github/client.js';
4
4
  import { fetchAllRepositories, normalizeRepository } from '../github/repos.js';
5
5
  import { inspectRepositoryStructure } from '../github/repo-inspection.js';
@@ -15,27 +15,27 @@ import { progress, success, error, warn, fatal } from '../utils/output.js';
15
15
 
16
16
  export async function runAnalyzeCommand(options = {}) {
17
17
  const startTime = Date.now();
18
- const env = getEnv();
18
+ let args = { ...options };
19
+
20
+ args = await promptMissingKeys(args, {
21
+ quiet: args.quiet,
22
+ required: [
23
+ { key: 'githubToken', label: 'GitHub Personal Access Token' }
24
+ ],
25
+ optional: []
26
+ });
19
27
 
20
28
  let token;
21
29
  try {
22
- token = requireGithubToken(env);
30
+ token = requireGithubToken(args);
23
31
  } catch (err) {
24
- fatal('GITHUB_TOKEN missing — set it in .env: GITHUB_TOKEN=your_token');
32
+ fatal('GITHUB_TOKEN missing — set it in .env or pass --github-token');
25
33
  throw err;
26
34
  }
27
35
 
28
36
  const github = new GithubClient(token);
29
- const asOfDate = resolveAsOfDate(typeof options['as-of'] === 'string' ? options['as-of'] : undefined);
30
- const outputDir = typeof options['output-dir'] === 'string' ? options['output-dir'] : 'output';
31
-
32
- printHeader({
33
- command: 'analyze',
34
- asOfDate,
35
- outputDir,
36
- hasToken: Boolean(token),
37
- hasPolicy: false,
38
- });
37
+ const asOfDate = resolveAsOfDate(typeof args['as-of'] === 'string' ? args['as-of'] : undefined);
38
+ const outputDir = typeof args['output-dir'] === 'string' ? args['output-dir'] : 'output';
39
39
 
40
40
  let user;
41
41
  try {
@@ -49,9 +49,18 @@ export async function runAnalyzeCommand(options = {}) {
49
49
  throw err;
50
50
  }
51
51
 
52
+ printHeader({
53
+ command: 'analyze',
54
+ asOfDate,
55
+ outputDir,
56
+ hasToken: Boolean(token),
57
+ hasPolicy: false,
58
+ username: args.githubUsername || user.login
59
+ });
60
+
52
61
  let repositories;
53
62
  try {
54
- repositories = await fetchAllRepositories(github);
63
+ repositories = await fetchAllRepositories(github, asOfDate);
55
64
  } catch (err) {
56
65
  if (err && (err.status === 401 || err.status === 403)) {
57
66
  fatal('GitHub authentication failed — check your GITHUB_TOKEN permissions');
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import { buildReportModel } from '../core/report.js';
3
+ import { createPublicAliasLLMCaller, generatePublicAlias } from '../core/publicAliasGenerator.js';
3
4
  import { loadPresentationOverrides, applyPresentationOverrides } from '../core/presentationOverrides.js';
5
+ import { getEnv } from '../config.js';
4
6
  import { readJsonFile, readJsonFileIfExists } from '../io/files.js';
5
7
  import { writeReportAscii, writeReportJson, writeReportMarkdown } from '../io/report.js';
6
8
  import { UsageError } from '../errors.js';
@@ -46,11 +48,38 @@ export async function runReportCommand(options = {}) {
46
48
  const presentationOverridesPath = resolvePresentationOverridesPath(options);
47
49
  const presentationOverrides = await loadPresentationOverrides(presentationOverridesPath);
48
50
  const reportModel = buildReportModel(portfolio, inventory, { policyOverlay });
51
+ const portfolioDescriptionBySlug = new Map(
52
+ (Array.isArray(portfolio?.items) ? portfolio.items : []).map((item) => [
53
+ String(item?.slug ?? '').trim(),
54
+ item?.description ?? ''
55
+ ])
56
+ );
57
+ const callLLM = createPublicAliasLLMCaller(getEnv(options));
49
58
 
50
59
  if (presentationOverrides.size > 0) {
51
60
  reportModel.items = applyPresentationOverrides(reportModel.items, presentationOverrides);
52
61
  }
53
62
 
63
+ if (typeof callLLM === 'function') {
64
+ const privateItems = reportModel.items.filter((item) => item.private && !item.publicAlias);
65
+ const aliasBySlug = new Map();
66
+
67
+ for (const item of privateItems) {
68
+ const itemForAlias = {
69
+ ...item,
70
+ description: portfolioDescriptionBySlug.get(String(item.slug ?? '').trim()) ?? ''
71
+ };
72
+ item.publicAlias = await generatePublicAlias(itemForAlias, callLLM);
73
+ if (item.publicAlias) {
74
+ aliasBySlug.set(item.slug, item.publicAlias);
75
+ }
76
+ }
77
+
78
+ if (aliasBySlug.size > 0) {
79
+ applyAliasesToReportModel(reportModel, aliasBySlug);
80
+ }
81
+ }
82
+
54
83
  const writtenPaths = [];
55
84
 
56
85
  if (formatOption === 'json' || formatOption === 'all') {
@@ -127,6 +156,27 @@ function validatePolicyOverlay(policy, policyPath) {
127
156
  }
128
157
  }
129
158
 
159
+ function applyAliasesToReportModel(reportModel, aliasBySlug) {
160
+ for (const item of reportModel.items) {
161
+ if (!item.private || !item.publicAlias) {
162
+ continue;
163
+ }
164
+
165
+ item.slug = item.publicAlias;
166
+ item.title = item.publicAlias;
167
+ }
168
+
169
+ const sections = ['top10ByScore', 'now', 'next', 'later', 'park'];
170
+ for (const section of sections) {
171
+ for (const item of reportModel.summary[section] ?? []) {
172
+ const alias = aliasBySlug.get(item.slug);
173
+ if (alias) {
174
+ item.slug = alias;
175
+ }
176
+ }
177
+ }
178
+ }
179
+
130
180
  function printNowExplain(reportModel) {
131
181
  const nowItems = Array.isArray(reportModel?.items)
132
182
  ? reportModel.items.filter((item) => item.priorityBand === 'now')
@@ -185,7 +235,7 @@ function computeStateAdjustment(state) {
185
235
  return 5;
186
236
  }
187
237
 
188
- if (state === 'abandoned' || state === 'archived') {
238
+ if (state === 'dormant' || state === 'abandoned' || state === 'archived') {
189
239
  return -20;
190
240
  }
191
241
 
package/src/config.js CHANGED
@@ -1,18 +1,105 @@
1
1
  import dotenv from 'dotenv';
2
+ import { createInterface } from 'node:readline';
2
3
 
3
4
  dotenv.config({ quiet: true });
4
5
 
5
- export function getEnv() {
6
+ /**
7
+ * Returns env vars with optional CLI overrides.
8
+ * args uses camelCase keys, for example { githubToken: '...' }.
9
+ */
10
+ export function getEnv(args = {}) {
6
11
  return {
7
- githubToken: process.env.GITHUB_TOKEN ?? '',
8
- githubUsername: process.env.GITHUB_USERNAME ?? ''
12
+ githubToken: args.githubToken ?? process.env.GITHUB_TOKEN ?? '',
13
+ githubUsername: args.githubUsername ?? process.env.GITHUB_USERNAME ?? '',
14
+ openaiKey: args.openaiKey ?? process.env.OPENAI_API_KEY ?? '',
15
+ geminiKey: args.geminiKey ?? process.env.GEMINI_API_KEY ?? '',
16
+ anthropicKey: args.anthropicKey ?? process.env.ANTHROPIC_API_KEY ?? ''
9
17
  };
10
18
  }
11
19
 
12
- export function requireGithubToken(env = getEnv()) {
20
+ export function requireGithubToken(args = {}) {
21
+ const env = getEnv(args);
22
+
13
23
  if (!env.githubToken) {
14
- throw new Error('Missing GITHUB_TOKEN. Add it to your .env file before running analyze.');
24
+ throw new Error(
25
+ 'Missing GITHUB_TOKEN. Add it to your .env file or pass --github-token <token>'
26
+ );
15
27
  }
16
28
 
17
29
  return env.githubToken;
18
30
  }
31
+
32
+ /**
33
+ * Interactive terminal prompt for missing keys.
34
+ * Only runs on TTY and when quiet !== true.
35
+ */
36
+ export async function promptMissingKeys(
37
+ args = {},
38
+ { required = [], optional = [], quiet = false, input = process.stdin, output = process.stderr } = {}
39
+ ) {
40
+ if (quiet || !input?.isTTY) {
41
+ return args;
42
+ }
43
+
44
+ const env = getEnv(args);
45
+ const result = { ...args };
46
+ const rl = createInterface({ input, output });
47
+ rl.stdoutMuted = false;
48
+
49
+ const askVisible = (label, hint) =>
50
+ new Promise((resolve) => {
51
+ rl.question(` ${label}${hint ? ` (${hint})` : ''}: `, resolve);
52
+ });
53
+
54
+ const askSilent = (label, hint) =>
55
+ new Promise((resolve) => {
56
+ const originalWriteToOutput = rl._writeToOutput;
57
+ const hintText = hint ? ` (${hint})` : '';
58
+ rl.output.write(` ${label}${hintText}: `);
59
+ rl.stdoutMuted = true;
60
+ rl._writeToOutput = (str) => {
61
+ if (rl.stdoutMuted) {
62
+ rl.output.write('');
63
+ return;
64
+ }
65
+
66
+ rl.output.write(str);
67
+ };
68
+
69
+ rl.question('', (value) => {
70
+ rl.stdoutMuted = false;
71
+ rl._writeToOutput = originalWriteToOutput;
72
+ rl.output.write('\n');
73
+ resolve(value);
74
+ });
75
+ });
76
+
77
+ for (const { key, label } of required) {
78
+ if (env[key]) {
79
+ continue;
80
+ }
81
+
82
+ const value = await askSilent(label, 'required');
83
+ if (!value.trim()) {
84
+ rl.close();
85
+ throw new Error(`${label} is required.`);
86
+ }
87
+
88
+ result[key] = value.trim();
89
+ }
90
+
91
+ for (const { key, label } of optional) {
92
+ if (env[key]) {
93
+ continue;
94
+ }
95
+
96
+ const prompt = key === 'githubUsername' ? askVisible : askSilent;
97
+ const value = await prompt(label, 'optional, Enter to skip');
98
+ if (value.trim()) {
99
+ result[key] = value.trim();
100
+ }
101
+ }
102
+
103
+ rl.close();
104
+ return result;
105
+ }
@@ -11,7 +11,7 @@ export function classifyActivity(pushedAt, asOfDate) {
11
11
  return 'stale';
12
12
  }
13
13
 
14
- return 'abandoned';
14
+ return 'dormant';
15
15
  }
16
16
 
17
17
  export function classifyMaturity(sizeKb) {
@@ -0,0 +1,202 @@
1
+ const OPENAI_URL = 'https://api.openai.com/v1/responses';
2
+ const GEMINI_URL =
3
+ 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
4
+ const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
5
+
6
+ /**
7
+ * Generates a plausible, non-identifying alias for private repositories.
8
+ * Preserves manually curated aliases when present.
9
+ */
10
+ export async function generatePublicAlias(item, callLLM) {
11
+ if (!item?.private) {
12
+ return null;
13
+ }
14
+
15
+ if (item.publicAlias) {
16
+ return item.publicAlias;
17
+ }
18
+
19
+ if (typeof callLLM !== 'function') {
20
+ return null;
21
+ }
22
+
23
+ const prompt = [
24
+ 'Given a private software project with:',
25
+ `- category: ${item.category ?? 'unknown'}`,
26
+ `- language: ${item.language ?? 'unknown'}`,
27
+ `- topics: ${Array.isArray(item.topics) ? item.topics.join(', ') : 'none'}`,
28
+ `- description: "${String(item.description ?? '').substring(0, 200)}"`,
29
+ '',
30
+ 'Generate a plausible but fictional project slug (2-3 words, kebab-case).',
31
+ 'Must reflect the technical domain. Must NOT contain: original repo name,',
32
+ 'company names, client names, person names, or any identifying information.',
33
+ 'Return ONLY the slug, nothing else. Example: "relay-task-engine"'
34
+ ].join('\n');
35
+
36
+ try {
37
+ const raw = await callLLM(prompt);
38
+ return (
39
+ raw
40
+ ?.trim()
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9-]/g, '-')
43
+ .replace(/-+/g, '-')
44
+ .replace(/^-|-$/g, '') || null
45
+ );
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function createPublicAliasLLMCaller(env = {}) {
52
+ const providers = [];
53
+
54
+ if (env.openaiKey) {
55
+ providers.push((prompt) => callOpenAI(env.openaiKey, prompt));
56
+ }
57
+
58
+ if (env.geminiKey) {
59
+ providers.push((prompt) => callGemini(env.geminiKey, prompt));
60
+ }
61
+
62
+ if (env.anthropicKey) {
63
+ providers.push((prompt) => callAnthropic(env.anthropicKey, prompt));
64
+ }
65
+
66
+ if (providers.length === 0) {
67
+ return null;
68
+ }
69
+
70
+ return async function callLLM(prompt) {
71
+ let lastError = null;
72
+
73
+ for (const provider of providers) {
74
+ try {
75
+ const value = await provider(prompt);
76
+ if (typeof value === 'string' && value.trim()) {
77
+ return value;
78
+ }
79
+ } catch (error) {
80
+ lastError = error;
81
+ }
82
+ }
83
+
84
+ if (lastError) {
85
+ throw lastError;
86
+ }
87
+
88
+ return null;
89
+ };
90
+ }
91
+
92
+ async function callOpenAI(apiKey, prompt) {
93
+ const data = await postJson(
94
+ OPENAI_URL,
95
+ {
96
+ model: 'gpt-4.1-mini',
97
+ input: prompt,
98
+ max_output_tokens: 40
99
+ },
100
+ {
101
+ Authorization: `Bearer ${apiKey}`
102
+ }
103
+ );
104
+
105
+ return extractOpenAIText(data);
106
+ }
107
+
108
+ async function callGemini(apiKey, prompt) {
109
+ const data = await postJson(
110
+ `${GEMINI_URL}?key=${encodeURIComponent(apiKey)}`,
111
+ {
112
+ contents: [
113
+ {
114
+ role: 'user',
115
+ parts: [{ text: prompt }]
116
+ }
117
+ ],
118
+ generationConfig: {
119
+ temperature: 0.2,
120
+ maxOutputTokens: 40
121
+ }
122
+ }
123
+ );
124
+
125
+ return data?.candidates?.[0]?.content?.parts
126
+ ?.map((part) => part?.text ?? '')
127
+ .join('')
128
+ .trim();
129
+ }
130
+
131
+ async function callAnthropic(apiKey, prompt) {
132
+ const data = await postJson(
133
+ ANTHROPIC_URL,
134
+ {
135
+ model: 'claude-3-5-haiku-latest',
136
+ max_tokens: 40,
137
+ messages: [
138
+ {
139
+ role: 'user',
140
+ content: prompt
141
+ }
142
+ ]
143
+ },
144
+ {
145
+ 'x-api-key': apiKey,
146
+ 'anthropic-version': '2023-06-01'
147
+ }
148
+ );
149
+
150
+ return data?.content
151
+ ?.map((part) => (part?.type === 'text' ? part.text : ''))
152
+ .join('')
153
+ .trim();
154
+ }
155
+
156
+ async function postJson(url, body, headers = {}) {
157
+ const response = await fetch(url, {
158
+ method: 'POST',
159
+ headers: {
160
+ 'content-type': 'application/json',
161
+ ...headers
162
+ },
163
+ body: JSON.stringify(body)
164
+ });
165
+
166
+ const data = await safeJson(response);
167
+ if (!response.ok) {
168
+ const details = data?.error?.message ?? data?.message ?? response.statusText;
169
+ throw new Error(`LLM request failed: ${response.status} ${details}`.trim());
170
+ }
171
+
172
+ return data;
173
+ }
174
+
175
+ async function safeJson(response) {
176
+ const contentType = response.headers.get('content-type') ?? '';
177
+ if (!contentType.includes('application/json')) {
178
+ return null;
179
+ }
180
+
181
+ try {
182
+ return await response.json();
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function extractOpenAIText(data) {
189
+ if (typeof data?.output_text === 'string' && data.output_text.trim()) {
190
+ return data.output_text.trim();
191
+ }
192
+
193
+ for (const output of data?.output ?? []) {
194
+ for (const content of output?.content ?? []) {
195
+ if (typeof content?.text === 'string' && content.text.trim()) {
196
+ return content.text.trim();
197
+ }
198
+ }
199
+ }
200
+
201
+ return null;
202
+ }
@@ -1,6 +1,6 @@
1
1
  import { utcNowISOString } from '../utils/time.js';
2
2
 
3
- const STATE_ORDER = ['active', 'stale', 'abandoned', 'archived', 'idea', 'reference-only'];
3
+ const STATE_ORDER = ['active', 'stale', 'dormant', 'abandoned', 'archived', 'idea', 'reference-only'];
4
4
  const EFFORT_ORDER = ['xs', 's', 'm', 'l', 'xl'];
5
5
  const BAND_STRENGTH = {
6
6
  park: 0,
@@ -109,9 +109,9 @@ export function computePriorityBand(input) {
109
109
  } else if (state === 'stale') {
110
110
  priorityScore += 5;
111
111
  reasons.push('State boost: stale (+5)');
112
- } else if (state === 'abandoned' || state === 'archived') {
112
+ } else if (state === 'dormant' || state === 'abandoned' || state === 'archived') {
113
113
  priorityScore -= 20;
114
- reasons.push('State penalty: abandoned/archived (-20)');
114
+ reasons.push('State penalty: dormant/abandoned/archived (-20)');
115
115
  }
116
116
 
117
117
  if (completionLevel >= 1 && completionLevel <= 3) {
@@ -142,8 +142,15 @@ export function buildReportModel(portfolioData, inventoryData = null, options =
142
142
  const inventoryLookup = buildInventoryLookup(inventoryItems);
143
143
 
144
144
  const reportItems = portfolioItems.map((item) => {
145
- const slug = String(item.slug ?? '').trim();
146
- const inventorySignals = inventoryLookup.get(slug) ?? null;
145
+ const rawSlug = String(item.slug ?? '').trim();
146
+ const inventorySignals = inventoryLookup.get(rawSlug) ?? null;
147
+ const isPrivate = Boolean(item.private);
148
+ const alias = typeof item.publicAlias === 'string' && item.publicAlias.trim()
149
+ ? item.publicAlias.trim()
150
+ : null;
151
+ const slug = isPrivate && alias ? alias : rawSlug;
152
+ const rawTitle = resolveTitle(item);
153
+ const title = isPrivate && alias ? alias : rawTitle;
147
154
 
148
155
  const completionLevel = computeCompletionLevel(item, inventorySignals);
149
156
  const effortEstimate = computeEffortEstimate(item, completionLevel, inventorySignals);
@@ -163,9 +170,9 @@ export function buildReportModel(portfolioData, inventoryData = null, options =
163
170
  } = applyPolicyOverlayToItem(
164
171
  {
165
172
  ...item,
166
- slug,
173
+ slug: rawSlug,
167
174
  type: resolveItemType(item),
168
- title: resolveTitle(item),
175
+ title: rawTitle,
169
176
  tags: collectItemTags(item)
170
177
  },
171
178
  {
@@ -180,7 +187,7 @@ export function buildReportModel(portfolioData, inventoryData = null, options =
180
187
  return {
181
188
  slug,
182
189
  type: resolveItemType(item),
183
- title: resolveTitle(item),
190
+ title,
184
191
  score: Number(item.score ?? 0),
185
192
  state: String(item.state ?? 'idea'),
186
193
  effort: normalizeEffort(item.effort) ?? 'm',
@@ -199,9 +206,15 @@ export function buildReportModel(portfolioData, inventoryData = null, options =
199
206
  // presentation fields — passed directly from portfolio item
200
207
  ...(item.language != null ? { language: item.language } : {}),
201
208
  ...(Array.isArray(item.topics) && item.topics.length > 0 ? { topics: item.topics } : {}),
202
- ...(item.htmlUrl != null ? { htmlUrl: item.htmlUrl } : {}),
203
- ...(item.homepage != null ? { homepage: item.homepage } : {}),
204
- ...(item.category != null ? { category: item.category } : {})
209
+ ...(!isPrivate && item.htmlUrl != null ? { htmlUrl: item.htmlUrl } : {}),
210
+ ...(!isPrivate && item.homepage != null ? { homepage: item.homepage } : {}),
211
+ ...(item.category != null ? { category: item.category } : {}),
212
+ ...(item.fork != null ? { fork: Boolean(item.fork) } : {}),
213
+ ...(item.forkType != null ? { forkType: item.forkType } : {}),
214
+ ...(item.private != null ? { private: Boolean(item.private) } : {}),
215
+ ...(item.publicAlias != null ? { publicAlias: item.publicAlias } : {}),
216
+ ...(!isPrivate && item.description != null ? { description: item.description } : {}),
217
+ ...(isPrivate && item.description != null ? { _description: item.description } : {})
205
218
  };
206
219
  });
207
220
 
@@ -260,7 +273,12 @@ export function buildReportModel(portfolioData, inventoryData = null, options =
260
273
  matrix: {
261
274
  completionByEffort: matrix
262
275
  },
263
- items: sortedByPriority.map(({ priorityScore: _priorityScore, ...item }) => item)
276
+ items: sortedByPriority.map((item) => {
277
+ const publicItem = { ...item };
278
+ delete publicItem.priorityScore;
279
+ delete publicItem._description;
280
+ return publicItem;
281
+ })
264
282
  };
265
283
  }
266
284
 
@@ -291,6 +309,7 @@ function buildByStateSummary(reportItems) {
291
309
  const summary = {
292
310
  active: 0,
293
311
  stale: 0,
312
+ dormant: 0,
294
313
  abandoned: 0,
295
314
  archived: 0,
296
315
  idea: 0,
@@ -6,13 +6,14 @@ function inferRepoCategory(repository) {
6
6
  const topics = Array.isArray(repository.topics)
7
7
  ? repository.topics.map((topic) => String(topic).toLowerCase())
8
8
  : [];
9
+ const nameAndTopics = [name, ...topics].join(' ');
9
10
  const all = [name, desc, ...topics].join(' ');
10
11
 
11
12
  if (/\b(prompt|note|notes|snippet|snippets|cheatsheet|doc|docs|documentation|knowledge|wiki|resource|resources|writing|content|guide|guides|cookbook)\b/.test(all)) {
12
13
  return 'content';
13
14
  }
14
15
 
15
- if (/\b(learn|learning|study|exercise|exercises|course|tutorial|tutorials|practice|training|bootcamp|challenge|challenges|kata)\b/.test(all)) {
16
+ if (/\b(learn|learning|study|exercise|exercises|course|tutorial|tutorials|practice|training|bootcamp|challenge|challenges|kata|curriculum|syllabus)\b/.test(all)) {
16
17
  return 'learning';
17
18
  }
18
19
 
@@ -20,6 +21,10 @@ function inferRepoCategory(repository) {
20
21
  return 'template';
21
22
  }
22
23
 
24
+ if (/\b(poc|proof|experiment|spike|prototype|sandbox|playground)\b/.test(nameAndTopics)) {
25
+ return 'experiment';
26
+ }
27
+
23
28
  if (/\b(lib|library|sdk|package|npm|module|plugin|extension|addon|util|utils|helper|helpers)\b/.test(all)) {
24
29
  return 'library';
25
30
  }
@@ -28,11 +33,11 @@ function inferRepoCategory(repository) {
28
33
  return 'infra';
29
34
  }
30
35
 
31
- if (/\b(poc|proof|experiment|spike|demo|prototype|sandbox|playground|try|trying)\b/.test(all)) {
36
+ if (/\b(demo|try|trying)\b/.test(all)) {
32
37
  return 'experiment';
33
38
  }
34
39
 
35
- if (/\b(app|application|system|platform|service|api|backend|frontend|web|mobile|dashboard|portal|saas)\b/.test(all)) {
40
+ if (/\b(app|application|system|platform|service|api|backend|frontend|web|mobile|dashboard|portal|saas|clock|calculator|game|games|viewer|weather|timer|todo|player|tracker)\b/.test(all)) {
36
41
  return 'product';
37
42
  }
38
43
 
@@ -130,6 +135,10 @@ export function mapIdeaStatusToState(status) {
130
135
  return 'stale';
131
136
  }
132
137
 
138
+ if (value === 'dormant') {
139
+ return 'dormant';
140
+ }
141
+
133
142
  if (value === 'abandoned' || value === 'dropped') {
134
143
  return 'abandoned';
135
144
  }
@@ -154,7 +163,7 @@ function defaultRepoNextAction(state) {
154
163
  return formatNextAction('Refresh', 'execution documentation', 'README run steps are validated in a clean environment');
155
164
  }
156
165
 
157
- if (state === 'abandoned') {
166
+ if (state === 'dormant' || state === 'abandoned') {
158
167
  return formatNextAction('Decide', 'retain or archive status', 'README contains a documented decision and rationale');
159
168
  }
160
169
 
@@ -170,7 +179,7 @@ function normalizeCategory(value) {
170
179
  }
171
180
 
172
181
  function normalizeState(value, fallback) {
173
- return normalizeEnum(value, ['idea', 'active', 'stale', 'abandoned', 'archived', 'reference-only']) ?? fallback;
182
+ return normalizeEnum(value, ['idea', 'active', 'stale', 'dormant', 'abandoned', 'archived', 'reference-only']) ?? fallback;
174
183
  }
175
184
 
176
185
  function normalizeStrategy(value) {
@@ -15,6 +15,7 @@ export class GithubClient {
15
15
  constructor(token) {
16
16
  this.token = token;
17
17
  this.maxRetries = 4;
18
+ this._delay = sleepMs;
18
19
  }
19
20
 
20
21
  async request(path, init = {}) {
@@ -42,7 +43,7 @@ export class GithubClient {
42
43
  responseHeaders: response.headers,
43
44
  attempt
44
45
  });
45
- await sleepMs(delayMs);
46
+ await this._delay(delayMs);
46
47
  continue;
47
48
  }
48
49
 
@@ -37,8 +37,12 @@ async function readPackageJson(client, repository) {
37
37
  try {
38
38
  const response = await client.request(repoApiPath(repository, 'contents/package.json'));
39
39
  if (response?.content && response?.encoding === 'base64') {
40
- const decoded = Buffer.from(response.content, 'base64').toString('utf8');
41
- return JSON.parse(decoded);
40
+ try {
41
+ const decoded = Buffer.from(response.content, 'base64').toString('utf8');
42
+ return JSON.parse(decoded);
43
+ } catch {
44
+ return null;
45
+ }
42
46
  }
43
47
  return null;
44
48
  } catch (error) {
@@ -1,6 +1,47 @@
1
+ import { daysSince } from '../core/classification.js';
2
+
1
3
  const PAGE_SIZE = 100;
2
4
 
3
- export async function fetchAllRepositories(client) {
5
+ /**
6
+ * Classifies a fork as active or passive.
7
+ * Active forks are either ahead of the upstream default branch or recently active
8
+ * when upstream comparison metadata is unavailable.
9
+ */
10
+ export async function classifyFork(client, repo, asOfDate = new Date().toISOString().slice(0, 10)) {
11
+ if (!repo?.fork) {
12
+ return null;
13
+ }
14
+
15
+ const parent = repo.parent;
16
+ const pushedAt = repo._pushedAt ?? repo.pushed_at ?? repo.pushedAt ?? null;
17
+ const isRecentlyActive = pushedAt ? daysSince(pushedAt, asOfDate) <= 90 : false;
18
+ const fallbackForkType = isRecentlyActive ? 'active' : 'passive';
19
+
20
+ if (!parent) {
21
+ return fallbackForkType;
22
+ }
23
+
24
+ const ownerLogin = repo.owner?.login ?? repo.ownerLogin;
25
+ const parentOwner = parent.owner?.login;
26
+ const parentBranch = parent.default_branch ?? parent.defaultBranch ?? 'main';
27
+ const branch = repo.default_branch ?? repo.defaultBranch ?? 'main';
28
+
29
+ if (!ownerLogin || !parentOwner || !repo.name) {
30
+ return fallbackForkType;
31
+ }
32
+
33
+ try {
34
+ const comparison = await client.request(
35
+ `/repos/${encodeURIComponent(ownerLogin)}/${encodeURIComponent(repo.name)}/compare/${encodeURIComponent(parentOwner)}:${encodeURIComponent(parentBranch)}...${encodeURIComponent(ownerLogin)}:${encodeURIComponent(branch)}`
36
+ );
37
+
38
+ return (comparison?.ahead_by ?? 0) > 0 ? 'active' : 'passive';
39
+ } catch {
40
+ return fallbackForkType;
41
+ }
42
+ }
43
+
44
+ export async function fetchAllRepositories(client, asOfDate = new Date().toISOString().slice(0, 10)) {
4
45
  const repositories = [];
5
46
 
6
47
  for (let page = 1; ; page += 1) {
@@ -26,6 +67,17 @@ export async function fetchAllRepositories(client) {
26
67
  }
27
68
 
28
69
  repositories.sort((left, right) => left.full_name.localeCompare(right.full_name));
70
+
71
+ const forks = repositories.filter((repository) => repository.fork);
72
+ for (let index = 0; index < forks.length; index += 5) {
73
+ const batch = forks.slice(index, index + 5);
74
+ await Promise.all(
75
+ batch.map(async (repository) => {
76
+ repository.forkType = await classifyFork(client, repository, asOfDate);
77
+ })
78
+ );
79
+ }
80
+
29
81
  return repositories;
30
82
  }
31
83
 
@@ -39,6 +91,8 @@ export function normalizeRepository(repo) {
39
91
  private: repo.private,
40
92
  archived: repo.archived,
41
93
  fork: repo.fork,
94
+ forkType: repo.forkType ?? null,
95
+ parent: repo.parent ?? null,
42
96
  htmlUrl: repo.html_url,
43
97
  description: repo.description,
44
98
  language: repo.language,
@@ -109,7 +109,7 @@ function renderPortfolioSummary(payload) {
109
109
 
110
110
  lines.push('');
111
111
 
112
- for (const state of ['active', 'stale', 'abandoned', 'idea']) {
112
+ for (const state of ['active', 'stale', 'dormant', 'abandoned', 'idea']) {
113
113
  lines.push(`## State: ${state}`);
114
114
  lines.push('');
115
115
 
@@ -13,9 +13,9 @@ ${AMBER} later█░░░ ↑${RESET}
13
13
  ${DIM} ↓${RESET}
14
14
  ${GREEN} ✓ report.json${RESET}`;
15
15
 
16
- export function printHeader({ command: _command, asOfDate, outputDir, hasToken, hasPolicy, version }) {
16
+ export function printHeader({ command: _command, asOfDate, outputDir, hasToken, hasPolicy, version, username }) {
17
17
  const node = process.version;
18
- const user = process.env.GITHUB_USERNAME ?? '—';
18
+ const user = username ?? process.env.GITHUB_USERNAME ?? '—';
19
19
  const token = hasToken ? `${GREEN}✓ set${RESET}` : `${AMBER}not set${RESET}`;
20
20
  const policy = hasPolicy ? `${GREEN}✓ set${RESET}` : `${GRAY}not set${RESET}`;
21
21
  const ver = version ?? packageJson.version;
package/src/utils/time.js CHANGED
@@ -14,6 +14,9 @@ export function resolveAsOfDate(input) {
14
14
  if (Number.isNaN(asDate.getTime())) {
15
15
  throw new Error(`Invalid --as-of value: ${input}. Expected YYYY-MM-DD.`);
16
16
  }
17
+ if (asDate.toISOString().slice(0, 10) !== input) {
18
+ throw new Error(`Invalid --as-of value: ${input}. Date does not exist in the calendar.`);
19
+ }
17
20
 
18
21
  return input;
19
22
  }