elliot-stack 1.0.29 → 1.0.33

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 (128) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +5 -0
  3. package/bin/install.cjs +981 -950
  4. package/hooks/repo-search-nudge.js +32 -32
  5. package/package.json +1 -1
  6. package/skills/estack-active-learning-tutor/SKILL.md +339 -339
  7. package/skills/estack-better-title/SKILL.md +64 -64
  8. package/skills/estack-better-title/scripts/rename.sh +55 -55
  9. package/skills/estack-chris-voss/SKILL.md +80 -80
  10. package/skills/estack-chris-voss/references/elliot-notes.md +120 -120
  11. package/skills/estack-chris-voss/references/voss-principles.md +210 -210
  12. package/skills/estack-customer-discovery/SKILL.md +60 -60
  13. package/skills/estack-flight-planner/SKILL.md +332 -332
  14. package/skills/estack-flight-planner/references/config_schema.md +156 -156
  15. package/skills/estack-flight-planner/references/flight_history_schema.md +97 -97
  16. package/skills/estack-flight-planner/references/shuttle_schedules.md +98 -98
  17. package/skills/estack-flight-planner/scripts/check_setup.sh +89 -89
  18. package/skills/estack-flight-planner/scripts/fetch_flights.py +99 -99
  19. package/skills/estack-flight-planner/scripts/filter_flights.py +265 -265
  20. package/skills/estack-flight-planner/scripts/pair_shuttles.py +173 -173
  21. package/skills/estack-github-issue-tracker/SKILL.md +322 -322
  22. package/skills/estack-github-issue-tracker/bin/tracker-tools.cjs +1358 -1358
  23. package/skills/estack-github-issue-tracker/references/gh-cli-patterns.md +124 -124
  24. package/skills/estack-github-issue-tracker/references/result-file-schema.md +156 -156
  25. package/skills/estack-github-issue-tracker/references/tracker-schema.md +96 -96
  26. package/skills/estack-github-issue-tracker/tracker-template.md +58 -58
  27. package/skills/estack-leadership-coach/SKILL.md +235 -0
  28. package/skills/estack-leadership-coach/adding-references.md +280 -0
  29. package/skills/estack-leadership-coach/frameworks/delegation/flows/post-mortem.md +120 -0
  30. package/skills/estack-leadership-coach/frameworks/delegation/flows/pre-delegation.md +138 -0
  31. package/skills/estack-leadership-coach/frameworks/delegation/phases/1-intake.md +145 -0
  32. package/skills/estack-leadership-coach/frameworks/delegation/phases/2-trm-assessment.md +119 -0
  33. package/skills/estack-leadership-coach/frameworks/delegation/phases/3-enrollment.md +132 -0
  34. package/skills/estack-leadership-coach/frameworks/delegation/phases/4-build-brief.md +171 -0
  35. package/skills/estack-leadership-coach/frameworks/delegation/phases/5-monitoring.md +134 -0
  36. package/skills/estack-leadership-coach/frameworks/delegation/phases/6-reverse-delegation.md +118 -0
  37. package/skills/estack-leadership-coach/frameworks/delegation/phases/7-diagnose.md +200 -0
  38. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__deci-olafsen-ryan-2017-self-determination-theory-in-work-organizations.md +1881 -0
  39. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__gagne-deci-2005-self-determination-theory-and-work-motivation.md +2058 -0
  40. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__selfdeterminationtheory-org-theory-overview-page.md +61 -0
  41. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-3-key-insights-into-the-global-workplace-2024.md +57 -0
  42. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-managers-account-for-70-percent-of-variance-in-employee-engagement-2015.md +40 -0
  43. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-global-data-summary.md +73 -0
  44. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-report-landing.md +42 -0
  45. package/skills/estack-leadership-coach/references/.source-files/hormozi-leila_4-stages__leila-hormozi-the-art-of-delegation-blog-post.md +91 -0
  46. package/skills/estack-leadership-coach/references/.source-files/oncken-wass_monkeys-hbr-1974__oncken-wass-management-time-whos-got-the-monkey-hbr-classic-1974.md +969 -0
  47. package/skills/estack-leadership-coach/references/.source-files/sanchez_main-street-millionaire__codie-sanchez-afford-anything-podcast-ep-565-show-notes.md +89 -0
  48. package/skills/estack-leadership-coach/references/.source-files/sullivan_who-not-how__dan-sullivan-impact-filter-tool-and-guide-booklet.md +565 -0
  49. package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-lewis-howes-school-of-greatness-ep-1231-show-notes.md +122 -0
  50. package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-roger-dooley-cues-interview.md +194 -0
  51. package/skills/estack-leadership-coach/references/deci-ryan_self-determination-theory.md +166 -0
  52. package/skills/estack-leadership-coach/references/doerr_measure-what-matters.md +154 -0
  53. package/skills/estack-leadership-coach/references/ferriss_4hww.md +189 -0
  54. package/skills/estack-leadership-coach/references/gallup_engagement-research.md +105 -0
  55. package/skills/estack-leadership-coach/references/gerber_e-myth-revisited.md +118 -0
  56. package/skills/estack-leadership-coach/references/grove_high-output-management.md +95 -0
  57. package/skills/estack-leadership-coach/references/hormozi-alex_followthrough.md +152 -0
  58. package/skills/estack-leadership-coach/references/hormozi-leila_4-stages.md +146 -0
  59. package/skills/estack-leadership-coach/references/oncken-wass_monkeys-hbr-1974.md +128 -0
  60. package/skills/estack-leadership-coach/references/sanchez_main-street-millionaire.md +196 -0
  61. package/skills/estack-leadership-coach/references/sullivan_who-not-how.md +137 -0
  62. package/skills/estack-leadership-coach/references/van-edwards_cues.md +189 -0
  63. package/skills/estack-migrate-claude-session-history/SKILL.md +226 -0
  64. package/skills/estack-migrate-claude-session-history/references/path-encoding.md +55 -0
  65. package/skills/estack-migrate-claude-session-history/references/troubleshooting.md +96 -0
  66. package/skills/estack-migrate-claude-session-history/scripts/migrate-claude-history.js +1123 -0
  67. package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +48 -0
  68. package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +326 -0
  69. package/skills/estack-migrate-claude-session-history/scripts/validate-migration.py +493 -0
  70. package/skills/estack-pdf-to-md/SKILL.md +180 -0
  71. package/skills/estack-pdf-to-md/scripts/pdf_to_md.py +596 -0
  72. package/skills/estack-productivity-prioritization-coach/SKILL.md +124 -0
  73. package/skills/estack-productivity-prioritization-coach/sources/01-tony-robbins-rpm.md +39 -0
  74. package/skills/estack-productivity-prioritization-coach/sources/02-justin-sung-task-prioritization.md +34 -0
  75. package/skills/estack-prompt-builder-coach/SKILL.md +81 -81
  76. package/skills/estack-prompt-builder-coach/definition-of-done-generator.md +42 -42
  77. package/skills/estack-prompt-builder-coach/prompt-builder.md +37 -37
  78. package/skills/estack-prompt-builder-coach/task-shaper.md +36 -36
  79. package/skills/estack-prompt-builder-coach/vague-ask-auditor.md +37 -37
  80. package/skills/estack-read-claude-session-history/SKILL.md +204 -204
  81. package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -126
  82. package/skills/estack-read-claude-session-history/references/modes.md +423 -423
  83. package/skills/estack-read-claude-session-history/references/recipes.md +271 -271
  84. package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -1
  85. package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -460
  86. package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -234
  87. package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -179
  88. package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -88
  89. package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -144
  90. package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1776 -1776
  91. package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -40
  92. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -20
  93. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -4
  94. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -2
  95. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +9 -9
  96. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +7 -7
  97. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +3 -3
  98. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +3 -3
  99. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +5 -5
  100. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -2
  101. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -8
  102. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -2
  103. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -2
  104. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -2
  105. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -2
  106. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -1
  107. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -4
  108. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -6
  109. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -5
  110. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -10
  111. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +2 -2
  112. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -2
  113. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -3
  114. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -5
  115. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -2
  116. package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -56
  117. package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +239 -239
  118. package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -201
  119. package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -199
  120. package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -195
  121. package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -133
  122. package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -78
  123. package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -43
  124. package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +179 -179
  125. package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -212
  126. package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -80
  127. package/skills/estack-repo-search/SKILL.md +65 -65
  128. package/skills/estack-vscode-file-recovery/SKILL.md +188 -0
@@ -1,1358 +1,1358 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * tracker-tools.cjs — Data service layer for estack-github-issue-tracker skill.
5
- *
6
- * Commands:
7
- * startup --tracker <path> Auth, parse tracker + config, create temp dir, discover issues
8
- * fetch-issues --temp-dir <dir> --issues <json-file> Parallel gh api fetches per issue
9
- * compile-report --temp-dir <dir> --date <YYYY-MM-DD> Build overview from result files
10
- * update-tracker --tracker <path> --temp-dir <dir> --date <YYYY-MM-DD> Apply changes (incl. Goal)
11
- * build-tracker --temp-dir <dir> --template <path> --username <name> --tracker <path> First-run tracker creation
12
- *
13
- * All commands return a `today` field with the current date.
14
- * Zero dependencies. Deterministic output. The agent calls this; it cannot "forget".
15
- */
16
-
17
- 'use strict';
18
-
19
- const fs = require('fs');
20
- const path = require('path');
21
- const os = require('os');
22
- const { exec, execSync } = require('child_process');
23
-
24
- function localDate() {
25
- const now = new Date();
26
- return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
27
- }
28
-
29
- function todayTag() {
30
- return `today's date is **${localDate()}**, ignore earlier dates`;
31
- }
32
-
33
- // ─── CLI Router ─────────────────────────────────────────────────────────────
34
-
35
- const [,, command, ...rawArgs] = process.argv;
36
-
37
- const commands = {
38
- 'compile-report': cmdCompileReport,
39
- 'update-tracker': cmdUpdateTracker,
40
- 'startup': cmdStartup,
41
- 'fetch-issues': cmdFetchIssues,
42
- 'build-tracker': cmdBuildTracker,
43
- };
44
-
45
- if (!command || !commands[command]) {
46
- console.error(`Usage: tracker-tools.cjs <command> [options]`);
47
- console.error(`Commands: ${Object.keys(commands).join(', ')}`);
48
- process.exit(1);
49
- }
50
-
51
- // Parse --flag value pairs from rawArgs
52
- function parseFlags(args) {
53
- const flags = {};
54
- for (let i = 0; i < args.length; i++) {
55
- if (args[i].startsWith('--')) {
56
- const key = args[i].slice(2);
57
- const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true;
58
- flags[key] = val;
59
- } else if (!flags._positional) {
60
- flags._positional = args[i];
61
- }
62
- }
63
- return flags;
64
- }
65
-
66
- const flags = parseFlags(rawArgs);
67
-
68
- (async () => {
69
- try {
70
- await commands[command](flags);
71
- } catch (err) {
72
- console.error(`Error in ${command}: ${err.message}`);
73
- process.exit(1);
74
- }
75
- })();
76
-
77
- // ─── Frontmatter Parser ────────────────────────────────────────────────────
78
-
79
- function parseFrontmatter(content) {
80
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
81
- if (!match) return { meta: {}, body: content };
82
-
83
- const meta = {};
84
- const lines = match[1].split(/\r?\n/);
85
- for (const line of lines) {
86
- const idx = line.indexOf(':');
87
- if (idx === -1) continue;
88
- const key = line.slice(0, idx).trim();
89
- let val = line.slice(idx + 1).trim();
90
-
91
- // Boolean
92
- if (val === 'true') val = true;
93
- else if (val === 'false') val = false;
94
- // Number
95
- else if (/^\d+$/.test(val)) val = parseInt(val, 10);
96
- // Comma-separated list
97
- else if (val.includes(',') && !val.startsWith('"')) {
98
- val = val.split(',').map(s => s.trim()).filter(Boolean);
99
- }
100
- // Strip surrounding quotes
101
- else if (val.startsWith('"') && val.endsWith('"')) {
102
- val = val.slice(1, -1);
103
- }
104
-
105
- meta[key] = val;
106
- }
107
-
108
- const body = content.slice(match[0].length).trim();
109
- return { meta, body };
110
- }
111
-
112
- // ─── Tracker Parser ─────────────────────────────────────────────────────────
113
-
114
- function parseTrackerFile(content) {
115
- const result = {
116
- username: null,
117
- config: null,
118
- active_issues: [],
119
- closed_issues: [],
120
- raw: content,
121
- };
122
-
123
- // Extract username
124
- const userMatch = content.match(/GitHub username:\s*\*\*(.+?)\*\*/);
125
- if (userMatch) result.username = userMatch[1];
126
-
127
- // Extract Config section (between ## Config and next --- or ##)
128
- const configMatch = content.match(/## Config\s*\n([\s\S]*?)(?=\n---|\n## (?!Config))/);
129
- if (configMatch) {
130
- // Strip HTML comments and trim
131
- result.config = configMatch[1].replace(/<!--[\s\S]*?-->/g, '').trim() || null;
132
- }
133
-
134
- // Split into Active and Closed sections
135
- const activeSectionMatch = content.match(
136
- /## Active Issues[^\n]*\n([\s\S]*?)(?=\n## (?!Active)|$)/
137
- );
138
- const closedSectionMatch = content.match(
139
- /## Closed[^\n]*\n([\s\S]*?)(?=\n## (?!Closed)|$)/
140
- );
141
-
142
- if (activeSectionMatch) {
143
- result.active_issues = parseIssueSections(activeSectionMatch[1]);
144
- }
145
- if (closedSectionMatch) {
146
- result.closed_issues = parseClosedSections(closedSectionMatch[1]);
147
- }
148
-
149
- return result;
150
- }
151
-
152
- function parseIssueSections(sectionContent) {
153
- const issues = [];
154
- // Split by ### headers
155
- const parts = sectionContent.split(/(?=^### )/m).filter(p => p.trim());
156
-
157
- for (const part of parts) {
158
- // Skip HTML comments
159
- if (part.trim().startsWith('<!--')) continue;
160
-
161
- const issue = parseIssueBlock(part);
162
- if (issue) issues.push(issue);
163
- }
164
- return issues;
165
- }
166
-
167
- function parseIssueBlock(block) {
168
- // Parse header: ### owner/repo#NUMBER — Title
169
- const headerMatch = block.match(
170
- /^### ([^/]+)\/([^#]+)#(\d+)\s*[—–-]\s*(.+)/m
171
- );
172
- if (!headerMatch) return null;
173
-
174
- const issue = {
175
- owner: headerMatch[1].trim(),
176
- repo: headerMatch[2].trim(),
177
- number: parseInt(headerMatch[3], 10),
178
- title: headerMatch[4].trim(),
179
- goal: extractField(block, 'Goal'),
180
- role: extractField(block, 'Role'),
181
- filed: extractField(block, 'Filed'),
182
- last_check_date: null,
183
- status_summary: extractField(block, 'Status as of'),
184
- what_to_check: extractField(block, 'What to check'),
185
- related: extractListField(block, 'Related'),
186
- upstream: extractUpstream(block),
187
- duplicates: extractNestedItems(block, 'Duplicates found'),
188
- adjacent: extractNestedItems(block, 'Adjacent issues found'),
189
- next_steps: extractField(block, 'Next steps \\(now\\)'),
190
- future: extractField(block, 'Future'),
191
- };
192
-
193
- // Extract last check date from "Status as of YYYY-MM-DD:"
194
- const dateMatch = block.match(/\*\*Status as of (\d{4}-\d{2}-\d{2}):\*\*/);
195
- if (dateMatch) issue.last_check_date = dateMatch[1];
196
-
197
- // Extract History field (multi-line: bullet list under **History:**)
198
- issue.history = extractHistoryField(block);
199
-
200
- return issue;
201
- }
202
-
203
- function extractField(block, fieldName) {
204
- // Special case: "Status as of YYYY-MM-DD:" has the date embedded in the bold
205
- if (fieldName === 'Status as of') {
206
- const statusRe = /\*\*Status as of \d{4}-\d{2}-\d{2}:\*\*\s*(.+)/i;
207
- const statusMatch = block.match(statusRe);
208
- return statusMatch ? statusMatch[1].trim() : null;
209
- }
210
- const re = new RegExp(`\\*\\*${fieldName}(?:\\s+\\S+)?[.:]*\\*\\*\\s*(.+)`, 'i');
211
- const match = block.match(re);
212
- return match ? match[1].trim() : null;
213
- }
214
-
215
- function extractListField(block, fieldName) {
216
- const val = extractField(block, fieldName);
217
- if (!val) return [];
218
- return val.split(',').map(s => s.trim()).filter(Boolean);
219
- }
220
-
221
- function extractUpstream(block) {
222
- const val = extractField(block, 'Upstream');
223
- if (!val || val.toLowerCase() === 'n/a' || val === 'none') return null;
224
- const match = val.match(/([^/]+)\/([^#]+)#(\d+)/);
225
- if (!match) return null;
226
- return { owner: match[1], repo: match[2], number: parseInt(match[3], 10) };
227
- }
228
-
229
- function extractNestedItems(block, sectionName) {
230
- const items = [];
231
- const re = new RegExp(`\\*\\*${sectionName}[^*]*\\*\\*[:\\s]*\\n([\\s\\S]*?)(?=\\n- \\*\\*[A-Z]|$)`, 'i');
232
- const match = block.match(re);
233
- if (!match) return items;
234
-
235
- const lines = match[1].split(/\r?\n/);
236
- for (const line of lines) {
237
- const itemMatch = line.match(/^\s+-\s+\*\*#?(\d+)\*\*\s*[—–-]\s*(.+)/);
238
- if (itemMatch) {
239
- items.push({
240
- number: parseInt(itemMatch[1], 10),
241
- detail: itemMatch[2].trim(),
242
- });
243
- }
244
- }
245
- return items;
246
- }
247
-
248
- /**
249
- * Extract history entries from an issue block.
250
- * Matches all " - **YYYY-MM-DD:** ..." lines under "**History:**".
251
- * Returns array of strings like "**2026-03-15:** Filed issue".
252
- */
253
- function extractHistoryField(block) {
254
- // Find **History:** and collect indented bullet lines until next top-level field or section
255
- const historyStart = block.search(/- \*\*History:\*\*/);
256
- if (historyStart === -1) return [];
257
-
258
- const afterHistory = block.slice(historyStart);
259
- // Collect lines after the **History:** line that match date bullets
260
- const lines = afterHistory.split(/\r?\n/);
261
- const entries = [];
262
- let started = false;
263
- for (const line of lines) {
264
- if (!started) {
265
- if (/- \*\*History:\*\*/.test(line)) { started = true; }
266
- continue;
267
- }
268
- // Match indented date bullets: " - **YYYY-MM-DD:** ..."
269
- const bullet = line.match(/^\s+-\s+(\*\*\d{4}-\d{2}-\d{2}:\*\*.+)/);
270
- if (bullet) {
271
- entries.push(bullet[1].trim());
272
- } else if (line.match(/^- \*\*[A-Z]/) || line.match(/^### /)) {
273
- // Next top-level field or section header — stop
274
- break;
275
- }
276
- }
277
- return entries;
278
- }
279
-
280
- function parseClosedSections(sectionContent) {
281
- const issues = [];
282
- const parts = sectionContent.split(/(?=^### )/m).filter(p => p.trim());
283
-
284
- for (const part of parts) {
285
- if (part.trim().startsWith('<!--')) continue;
286
- const headerMatch = part.match(
287
- /^### ([^/]+)\/([^#]+)#(\d+)\s*[—–-]\s*(.+)/m
288
- );
289
- if (!headerMatch) continue;
290
-
291
- const lines = part.split(/\r?\n/).slice(1).filter(l => l.trim().startsWith('-'));
292
- issues.push({
293
- owner: headerMatch[1].trim(),
294
- repo: headerMatch[2].trim(),
295
- number: parseInt(headerMatch[3], 10),
296
- title: headerMatch[4].trim(),
297
- resolution: lines.map(l => l.replace(/^-\s*/, '').trim()).join(' '),
298
- });
299
- }
300
- return issues;
301
- }
302
-
303
- // ─── Report Compiler ────────────────────────────────────────────────────────
304
-
305
- function compileOverviewReport(tempDir, date) {
306
- const files = fs.readdirSync(tempDir).filter(f => f.endsWith('.md'));
307
- if (files.length === 0) {
308
- return { error: 'No result files found in temp directory' };
309
- }
310
-
311
- const issueResults = [];
312
-
313
- for (const file of files) {
314
- const content = fs.readFileSync(path.join(tempDir, file), 'utf8');
315
- const { meta, body } = parseFrontmatter(content);
316
-
317
- if (meta.type === 'issue') {
318
- issueResults.push({ meta, body, file });
319
- }
320
- }
321
-
322
- // Sort: activity first, then by update recency
323
- const withActivity = issueResults.filter(r => r.meta.has_activity === true);
324
- const noActivity = issueResults.filter(r => r.meta.has_activity !== true);
325
-
326
- // Count repos
327
- const repos = new Set(issueResults.map(r => `${r.meta.owner}/${r.meta.repo}`));
328
-
329
- // Build report
330
- let report = '';
331
-
332
- report += `## GitHub Issues Check-In — ${date}\n\n`;
333
- report += `**Tracking ${issueResults.length} active issues across ${repos.size} repos**`;
334
-
335
- // Find oldest check date for "last check"
336
- const checkDates = issueResults
337
- .map(r => r.meta.last_check_date)
338
- .filter(Boolean)
339
- .sort();
340
- if (checkDates.length > 0) {
341
- report += ` | Last check: ${checkDates[0]}`;
342
- }
343
- report += '\n\n---\n\n';
344
-
345
- // ── Issues with Activity ──
346
- if (withActivity.length > 0) {
347
- report += '### Issues with Activity\n\n';
348
- report += `These ${withActivity.length} issue${withActivity.length === 1 ? ' has' : 's have'} new comments or state changes since your last check.\n\n`;
349
- for (const r of withActivity) {
350
- report += buildIssueDetailBlock(r);
351
- }
352
- }
353
-
354
- // ── No Activity ──
355
- if (noActivity.length > 0) {
356
- report += '### No Activity\n\n';
357
- for (const r of noActivity) {
358
- report += buildQuietIssueBlock(r);
359
- }
360
- }
361
-
362
- // ── Upstream Status ──
363
- const upstreamResults = issueResults.filter(r => {
364
- const upstreamSection = extractSection(r.body, 'Upstream');
365
- return upstreamSection && upstreamSection.trim() !== 'N/A' && upstreamSection.trim() !== 'None';
366
- });
367
- if (upstreamResults.length > 0) {
368
- report += '### Upstream Status\n\n';
369
- report += '| Upstream issue | State | Impact |\n';
370
- report += '|----------------|-------|--------|\n';
371
- for (const r of upstreamResults) {
372
- const section = extractSection(r.body, 'Upstream');
373
- report += `| ${r.meta.owner}/${r.meta.repo}#${r.meta.number} | ${section.trim()} |\n`;
374
- }
375
- report += '\n---\n\n';
376
- }
377
-
378
- // ── Summary ──
379
- const actionItems = withActivity.reduce((count, r) => {
380
- const steps = extractSection(r.body, 'Next Steps');
381
- if (steps) count += (steps.match(/^- \[/gm) || []).length;
382
- return count;
383
- }, 0);
384
-
385
- report += '### Summary\n\n';
386
- report += `**Action items:** ${actionItems}\n`;
387
- if (withActivity.length > 0) {
388
- const needAttention = withActivity.slice(0, 3).map(
389
- r => `${r.meta.owner}/${r.meta.repo}#${r.meta.number}`
390
- );
391
- report += `**Issues needing attention:** ${needAttention.join(', ')}\n`;
392
- }
393
- if (noActivity.length > 0) {
394
- report += `**All quiet:** ${noActivity.length} issues with no activity\n`;
395
- }
396
-
397
- return { report, issue_count: issueResults.length, activity_count: withActivity.length };
398
- }
399
-
400
- function buildIssueDetailBlock(r) {
401
- let block = '';
402
- block += `#### ${r.meta.owner}/${r.meta.repo}#${r.meta.number} — ${r.meta.title}\n`;
403
- block += `| Field | Value |\n`;
404
- block += `|-------|-------|\n`;
405
- block += `| **State** | ${r.meta.state || 'Open'}${r.meta.state_changed ? ' [changed]' : ''} |\n`;
406
- if (r.meta.labels) {
407
- const labels = Array.isArray(r.meta.labels) ? r.meta.labels.join(', ') : r.meta.labels;
408
- block += `| **Labels** | ${labels} |\n`;
409
- }
410
- block += `| **Your role** | ${r.meta.role || 'Unknown'} |\n\n`;
411
-
412
- // Plain English summary of where this issue stands
413
- const summary = extractSection(r.body, 'Status Summary');
414
- if (summary) {
415
- block += `${summary.trim()}\n\n`;
416
- }
417
-
418
- // Activity section — what changed since last check
419
- const activity = extractSection(r.body, 'Activity');
420
- if (activity) {
421
- block += `**What changed:**\n${activity}\n\n`;
422
- }
423
-
424
- // Duplicates
425
- const dupes = extractSection(r.body, 'Known');
426
- const newFinds = extractSection(r.body, 'New finds');
427
- const hasDupes = dupes && !/^(none|no changes)\.?$/i.test(dupes.trim());
428
- const hasNewFinds = newFinds && !/^(none|none found|skipped)\.?/i.test(newFinds.trim());
429
- if (hasDupes || hasNewFinds) {
430
- block += `**Duplicates & related:**\n`;
431
- if (hasDupes) block += `- Known dupes — ${dupes.trim()}\n`;
432
- if (hasNewFinds) block += `- New finds — ${newFinds.trim()}\n`;
433
- block += '\n';
434
- }
435
-
436
- // What you need to do next
437
- const steps = extractSection(r.body, 'Next Steps');
438
- if (steps) {
439
- block += `**What to do next:**\n${steps}\n\n`;
440
- }
441
-
442
- // Watch For
443
- const watch = extractSection(r.body, 'Watch For');
444
- if (watch) {
445
- block += `**Watch for:**\n${watch}\n\n`;
446
- }
447
-
448
- block += '---\n\n';
449
- return block;
450
- }
451
-
452
- function buildQuietIssueBlock(r) {
453
- let block = '';
454
- block += `#### ${r.meta.owner}/${r.meta.repo}#${r.meta.number} — ${r.meta.title}\n`;
455
- const lastDate = r.meta.last_comment_date || r.meta.last_check_date || 'unknown';
456
- block += `- Last activity: ${lastDate}\n`;
457
-
458
- // Keep quiet issues compact to avoid report bloat.
459
- const summary = extractSection(r.body, 'Status Summary');
460
- if (summary) {
461
- block += `- Status: ${firstLine(summary)}\n`;
462
- }
463
-
464
- const steps = extractSection(r.body, 'Next Steps');
465
- if (steps) {
466
- block += `- Next: ${firstLine(steps)}\n`;
467
- }
468
-
469
- const watch = extractSection(r.body, 'Watch For');
470
- if (watch) {
471
- block += `- Watch: ${firstLine(watch)}\n`;
472
- }
473
-
474
- block += '\n';
475
- return block;
476
- }
477
-
478
- function extractSection(body, headerPattern) {
479
- // Match ## Header or ### Header, capture until next ## or end
480
- const re = new RegExp(
481
- `^#{2,4}\\s+(?:[^\\n]*?${headerPattern}[^\\n]*)\\n([\\s\\S]*?)(?=^#{2,4}\\s|$)`,
482
- 'mi'
483
- );
484
- const match = body.match(re);
485
- return match ? match[1].trim() : null;
486
- }
487
-
488
- function firstLine(text) {
489
- if (!text) return '';
490
- const line = text.split(/\r?\n/).find(l => l.trim().length > 0) || '';
491
- return line.replace(/^\s*[-*]\s*/, '').trim();
492
- }
493
-
494
- // ─── Tracker Updater ────────────────────────────────────────────────────────
495
-
496
- function applyTrackerUpdates(trackerContent, tempDir, date) {
497
- const files = fs.readdirSync(tempDir).filter(f => f.endsWith('.md'));
498
- let updated = trackerContent;
499
- const changes = [];
500
-
501
- for (const file of files) {
502
- const content = fs.readFileSync(path.join(tempDir, file), 'utf8');
503
- const { meta, body } = parseFrontmatter(content);
504
-
505
- if (meta.type !== 'issue') continue;
506
-
507
- const issueKey = `${meta.owner}/${meta.repo}#${meta.number}`;
508
-
509
- // Find this issue's section in the tracker
510
- const sectionRe = new RegExp(
511
- `(### ${escapeRegex(meta.owner)}/${escapeRegex(meta.repo)}#${meta.number}\\s*[—–-][^\\n]*\\n[\\s\\S]*?)(?=\\n### |\\n## |$)`,
512
- 'm'
513
- );
514
- const sectionMatch = updated.match(sectionRe);
515
- if (!sectionMatch) continue;
516
-
517
- let section = sectionMatch[1];
518
- const originalSection = section;
519
-
520
- // Update "Status as of" date and summary
521
- const trackerUpdates = extractSection(body, 'Tracker Updates');
522
- if (trackerUpdates) {
523
- const statusLine = trackerUpdates.match(/^status_summary:\s*(.+)/m);
524
- if (statusLine) {
525
- section = section.replace(
526
- /\*\*Status as of \d{4}-\d{2}-\d{2}:\*\*\s*.+/,
527
- `**Status as of ${date}:** ${statusLine[1].trim()}`
528
- );
529
- }
530
-
531
- const watchLine = trackerUpdates.match(/^what_to_check:\s*(.+)/m);
532
- if (watchLine) {
533
- section = section.replace(
534
- /\*\*What to check\*\*:\s*.+/,
535
- `**What to check:** ${watchLine[1].trim()}`
536
- );
537
- }
538
-
539
- // Update or add Goal
540
- const goalLine = trackerUpdates.match(/^goal:\s*(.+)/m);
541
- if (goalLine) {
542
- if (section.includes('**Goal:**')) {
543
- section = section.replace(
544
- /\*\*Goal:\*\*\s*.+/,
545
- `**Goal:** ${goalLine[1].trim()}`
546
- );
547
- } else {
548
- // Insert Goal as first field after the header line
549
- section = section.replace(
550
- /(### [^\n]+\n)/,
551
- `$1- **Goal:** ${goalLine[1].trim()}\n`
552
- );
553
- }
554
- changes.push(`Set Goal for ${issueKey}`);
555
- }
556
- }
557
-
558
- // Handle state change: open → closed (move to Closed section)
559
- if (meta.state === 'closed' && meta.state_changed) {
560
- // Preserve key fields from the original section before removing it
561
- const closedLines = [];
562
- closedLines.push(`### ${issueKey} — ${meta.title}`);
563
- closedLines.push(`- Closed as of ${date}. ${meta.close_reason || ''}`);
564
-
565
- // Preserve Goal
566
- const goalMatch = originalSection.match(/- \*\*Goal:\*\*\s*(.+)/);
567
- if (goalMatch) closedLines.push(`- **Goal:** ${goalMatch[1].trim()}`);
568
-
569
- // Preserve Role
570
- const roleMatch = originalSection.match(/- \*\*Role:\*\*\s*(.+)/);
571
- if (roleMatch) closedLines.push(`- **Role:** ${roleMatch[1].trim()}`);
572
-
573
- // Preserve History section
574
- const historyIdx = originalSection.indexOf('- **History:**');
575
- if (historyIdx !== -1) {
576
- const historyBlock = originalSection.slice(historyIdx);
577
- const historyLines = historyBlock.split(/\r?\n/);
578
- const preserved = [historyLines[0]]; // "- **History:**"
579
- for (let hi = 1; hi < historyLines.length; hi++) {
580
- if (/^\s+-\s+\*\*\d{4}-\d{2}-\d{2}:\*\*/.test(historyLines[hi])) {
581
- preserved.push(historyLines[hi]);
582
- } else if (/^- \*\*[A-Z]/.test(historyLines[hi]) || /^### /.test(historyLines[hi])) {
583
- break;
584
- }
585
- }
586
- // Add closing history entry
587
- preserved.push(` - **${date}:** Closed. ${meta.close_reason || ''}`);
588
- closedLines.push(...preserved);
589
- }
590
-
591
- const closedEntry = closedLines.join('\n') + '\n\n';
592
-
593
- // Remove from active
594
- updated = updated.replace(originalSection, '');
595
- // Add to closed
596
- updated = updated.replace(
597
- /(## Closed[^\n]*\n)/,
598
- `$1\n${closedEntry}`
599
- );
600
- changes.push(`Moved ${issueKey} to Closed`);
601
- continue;
602
- }
603
-
604
- // Add new duplicates
605
- if (trackerUpdates) {
606
- const newDupeLines = [];
607
- const dupeRe = /new_duplicate:\s*#?(\d+)\s*[—–-]\s*(.+)/gm;
608
- let dupeMatch;
609
- while ((dupeMatch = dupeRe.exec(trackerUpdates)) !== null) {
610
- newDupeLines.push(` - **#${dupeMatch[1]}** — ${dupeMatch[2].trim()}`);
611
- }
612
- if (newDupeLines.length > 0) {
613
- const dupeHeader = `**Duplicates found (${date}):**`;
614
- if (section.includes('Duplicates found')) {
615
- // Append to existing duplicates section
616
- section = section.replace(
617
- /(Duplicates found[^*]*\*\*:?\s*\n(?:\s+-[^\n]*\n)*)/,
618
- `$1${dupeHeader}\n${newDupeLines.join('\n')}\n`
619
- );
620
- } else {
621
- // Add before Next steps
622
- section = section.replace(
623
- /(\*\*Next steps)/,
624
- `- ${dupeHeader}\n${newDupeLines.join('\n')}\n- $1`
625
- );
626
- }
627
- changes.push(`Added ${newDupeLines.length} duplicates to ${issueKey}`);
628
- }
629
- }
630
-
631
- // Append history entries from result file
632
- if (trackerUpdates) {
633
- const newHistoryEntries = [];
634
- const historyRe = /^history_entry:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*(.+)/gm;
635
- let hm;
636
- while ((hm = historyRe.exec(trackerUpdates)) !== null) {
637
- newHistoryEntries.push({ date: hm[1], desc: hm[2].trim() });
638
- }
639
-
640
- if (newHistoryEntries.length > 0) {
641
- const historyHeaderIdx = section.indexOf('- **History:**');
642
- if (historyHeaderIdx !== -1) {
643
- // History section exists — collect existing entries for dedup
644
- const afterHeader = section.slice(historyHeaderIdx);
645
- const existingLines = afterHeader.split(/\r?\n/);
646
- const existingTexts = new Set();
647
- for (const line of existingLines.slice(1)) {
648
- const m = line.match(/^\s+-\s+\*\*(\d{4}-\d{2}-\d{2}):\*\*\s*(.+)/);
649
- if (m) existingTexts.add(`${m[1]}|${m[2].trim()}`);
650
- else if (line.match(/^- \*\*[A-Z]/) || line.match(/^### /)) break;
651
- }
652
-
653
- // Build lines to append (deduped)
654
- const toAppend = newHistoryEntries
655
- .filter(e => !existingTexts.has(`${e.date}|${e.desc}`))
656
- .map(e => ` - **${e.date}:** ${e.desc}`);
657
-
658
- if (toAppend.length > 0) {
659
- // Find the last history bullet line position and insert after it
660
- // We'll insert the new lines before the next top-level field after **History:**
661
- const historyBlock = section.slice(historyHeaderIdx);
662
- const historyLines = historyBlock.split(/\r?\n/);
663
- let lastBulletLine = 0;
664
- for (let li = 1; li < historyLines.length; li++) {
665
- if (/^\s+-\s+\*\*\d{4}-\d{2}-\d{2}:\*\*/.test(historyLines[li])) {
666
- lastBulletLine = li;
667
- } else if (/^- \*\*[A-Z]/.test(historyLines[li]) || /^### /.test(historyLines[li])) {
668
- break;
669
- }
670
- }
671
- // Insert toAppend after lastBulletLine
672
- historyLines.splice(lastBulletLine + 1, 0, ...toAppend);
673
- const newHistoryBlock = historyLines.join('\n');
674
- section = section.slice(0, historyHeaderIdx) + newHistoryBlock;
675
- changes.push(`Appended ${toAppend.length} history entries to ${issueKey}`);
676
- }
677
- } else {
678
- // No history section yet — insert before trailing newline / end of section
679
- const insertPoint = section.lastIndexOf('\n');
680
- const historyLines = ['- **History:**'];
681
- for (const e of newHistoryEntries) {
682
- historyLines.push(` - **${e.date}:** ${e.desc}`);
683
- }
684
- section = section.slice(0, insertPoint) + '\n' + historyLines.join('\n') + section.slice(insertPoint);
685
- changes.push(`Added History section with ${newHistoryEntries.length} entries to ${issueKey}`);
686
- }
687
- }
688
- }
689
-
690
- if (section !== originalSection) {
691
- updated = updated.replace(originalSection, section);
692
- changes.push(`Updated ${issueKey}`);
693
- }
694
- }
695
-
696
- return { content: updated, changes };
697
- }
698
-
699
- function escapeRegex(str) {
700
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
701
- }
702
-
703
- // ─── Command Implementations ────────────────────────────────────────────────
704
-
705
- function cmdCompileReport(flags) {
706
- const tempDir = flags['temp-dir'];
707
- const date = flags.date;
708
-
709
- if (!tempDir || !date) {
710
- console.error('Usage: compile-report --temp-dir <dir> --date <YYYY-MM-DD>');
711
- process.exit(1);
712
- }
713
-
714
- if (!fs.existsSync(tempDir)) {
715
- console.error(`Temp directory not found: ${tempDir}`);
716
- process.exit(1);
717
- }
718
-
719
- const result = compileOverviewReport(tempDir, date);
720
- if (result.error) {
721
- console.error(result.error);
722
- process.exit(1);
723
- }
724
-
725
- // Write report to temp dir as well for validation
726
- fs.writeFileSync(path.join(tempDir, '_compiled-report.md'), result.report, 'utf8');
727
-
728
- // Output the report to stdout
729
- console.log(result.report);
730
-
731
- // Write metadata to stderr for the agent to parse
732
- console.error(JSON.stringify({
733
- today: todayTag(),
734
- issue_count: result.issue_count,
735
- activity_count: result.activity_count,
736
- report_file: path.join(tempDir, '_compiled-report.md'),
737
- }));
738
- }
739
-
740
- function cmdUpdateTracker(flags) {
741
- const trackerPath = flags.tracker;
742
- const tempDir = flags['temp-dir'];
743
- const date = flags.date;
744
-
745
- if (!trackerPath || !tempDir || !date) {
746
- console.error('Usage: update-tracker --tracker <path> --temp-dir <dir> --date <YYYY-MM-DD>');
747
- process.exit(1);
748
- }
749
-
750
- if (!fs.existsSync(trackerPath)) {
751
- console.error(`Tracker file not found: ${trackerPath}`);
752
- process.exit(1);
753
- }
754
-
755
- const content = fs.readFileSync(trackerPath, 'utf8');
756
- const { content: updated, changes } = applyTrackerUpdates(content, tempDir, date);
757
-
758
- if (changes.length === 0) {
759
- console.log(JSON.stringify({ today: todayTag(), updated: false, changes: [] }));
760
- return;
761
- }
762
-
763
- fs.writeFileSync(trackerPath, updated, 'utf8');
764
- console.log(JSON.stringify({ today: todayTag(), updated: true, changes }));
765
- }
766
-
767
- // ─── Async Helper ──────────────────────────────────────────────────────────
768
-
769
- function execAsync(cmd) {
770
- return new Promise((resolve, reject) => {
771
- exec(cmd, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
772
- if (err) reject(err);
773
- else resolve(stdout);
774
- });
775
- });
776
- }
777
-
778
- /**
779
- * Run promises in batches to avoid rate limits.
780
- * @param {Array<() => Promise>} tasks - Array of functions returning promises
781
- * @param {number} concurrency - Max concurrent tasks
782
- * @returns {Promise<Array>} Results in same order as tasks
783
- */
784
- async function batchRun(tasks, concurrency = 15) {
785
- const results = [];
786
- for (let i = 0; i < tasks.length; i += concurrency) {
787
- const batch = tasks.slice(i, i + concurrency);
788
- const batchResults = await Promise.all(batch.map(fn => fn()));
789
- results.push(...batchResults);
790
- }
791
- return results;
792
- }
793
-
794
- // ─── Command: startup ──────────────────────────────────────────────────────
795
-
796
- async function cmdStartup(flags) {
797
- const trackerPath = flags.tracker;
798
- if (!trackerPath) {
799
- console.error('Usage: startup --tracker <path>');
800
- process.exit(1);
801
- }
802
-
803
- // Step 1: Check gh auth
804
- let username = null;
805
- try {
806
- const authOutput = execSync('gh auth status 2>&1', { encoding: 'utf8' });
807
- const userMatch = authOutput.match(/Logged in to github\.com.*account\s+(\S+)/i)
808
- || authOutput.match(/Logged in to github\.com\s+as\s+(\S+)/i)
809
- || authOutput.match(/account\s+(\S+)/i);
810
- if (userMatch) username = userMatch[1];
811
- } catch (err) {
812
- console.log(JSON.stringify({
813
- script_ok: true, auth: false, today: todayTag(),
814
- error: 'Not authenticated with GitHub. Run `gh auth login` in your terminal to fix this.'
815
- }));
816
- return;
817
- }
818
-
819
- if (!username) {
820
- // Try alternate extraction from gh api
821
- try {
822
- username = execSync('gh api user --jq .login', { encoding: 'utf8' }).trim();
823
- } catch (_) {
824
- console.log(JSON.stringify({
825
- script_ok: true, auth: false, today: todayTag(),
826
- error: 'Not authenticated with GitHub. Run `gh auth login` in your terminal to fix this.'
827
- }));
828
- return;
829
- }
830
- }
831
-
832
- // Step 2: Parse tracker (or set empty defaults if no tracker)
833
- const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
834
- .toISOString().split('T')[0];
835
-
836
- let trackerExists = false;
837
- let parsed = { username: null, active_issues: [], closed_issues: [], raw: '' };
838
-
839
- if (fs.existsSync(trackerPath)) {
840
- const content = fs.readFileSync(trackerPath, 'utf8');
841
- if (content.trim()) {
842
- trackerExists = true;
843
- parsed = parseTrackerFile(content);
844
- // Default null last_check_date to 30 days ago
845
- for (const issue of parsed.active_issues) {
846
- if (!issue.last_check_date) issue.last_check_date = thirtyDaysAgo;
847
- }
848
- }
849
- }
850
-
851
- const oldestCheckDate = parsed.active_issues
852
- .map(i => i.last_check_date)
853
- .filter(Boolean)
854
- .sort()[0] || thirtyDaysAgo;
855
-
856
- const allTrackedNumbers = [
857
- ...parsed.active_issues.map(i => `${i.owner}/${i.repo}#${i.number}`),
858
- ...parsed.closed_issues.map(i => `${i.owner}/${i.repo}#${i.number}`),
859
- ];
860
-
861
- // Step 3: Run two gh api search queries in parallel (always, even without tracker)
862
- const searchDate = oldestCheckDate;
863
- const openQuery = `search/issues?q=involves:${username}+is:open+updated:>${searchDate}&per_page=100`;
864
- const closedQuery = `search/issues?q=involves:${username}+is:closed+closed:>${thirtyDaysAgo}&per_page=50`;
865
-
866
- let openResults = [];
867
- let closedResults = [];
868
- let searchErrors = [];
869
-
870
- // Run searches individually so one failure doesn't block the other
871
- try {
872
- const openRaw = await execAsync(`gh api "${openQuery}" --jq ".items"`);
873
- openResults = JSON.parse(openRaw || '[]');
874
- } catch (err) {
875
- searchErrors.push(`open search failed: ${err.message}`);
876
- openResults = [];
877
- }
878
-
879
- try {
880
- const closedRaw = await execAsync(`gh api "${closedQuery}" --jq ".items"`);
881
- closedResults = JSON.parse(closedRaw || '[]');
882
- } catch (err) {
883
- searchErrors.push(`closed search failed: ${err.message}`);
884
- closedResults = [];
885
- }
886
-
887
- // Step 4: Identify new, reopened, recently closed
888
- function issueKey(item) {
889
- const urlParts = (item.repository_url || '').split('/');
890
- const owner = urlParts[urlParts.length - 2];
891
- const repo = urlParts[urlParts.length - 1];
892
- return `${owner}/${repo}#${item.number}`;
893
- }
894
-
895
- function issueInfo(item) {
896
- const urlParts = (item.repository_url || '').split('/');
897
- return {
898
- owner: urlParts[urlParts.length - 2],
899
- repo: urlParts[urlParts.length - 1],
900
- number: item.number,
901
- title: item.title,
902
- state: item.state,
903
- updated_at: item.updated_at,
904
- created_at: item.created_at,
905
- html_url: item.html_url,
906
- labels: (item.labels || []).map(l => l.name),
907
- user: item.user ? item.user.login : null,
908
- };
909
- }
910
-
911
- const trackedSet = new Set(allTrackedNumbers);
912
- const closedNumbers = new Set(
913
- parsed.closed_issues.map(i => `${i.owner}/${i.repo}#${i.number}`)
914
- );
915
-
916
- const newIssues = openResults
917
- .filter(item => !trackedSet.has(issueKey(item)))
918
- .map(issueInfo);
919
-
920
- const reopenedIssues = openResults
921
- .filter(item => closedNumbers.has(issueKey(item)))
922
- .map(issueInfo);
923
-
924
- const recentlyClosed = closedResults.map(issueInfo);
925
-
926
- // Create temp directory for this session
927
- const prefix = 'giu-checkin-';
928
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
929
-
930
- console.log(JSON.stringify({
931
- script_ok: true,
932
- auth: true,
933
- today: todayTag(),
934
- username,
935
- temp_dir: tempDir,
936
- tracker_exists: trackerExists,
937
- tracker_path: trackerPath,
938
- config: parsed.config,
939
- tracker_data: {
940
- username: parsed.username,
941
- active_issues: parsed.active_issues,
942
- closed_issues: parsed.closed_issues,
943
- all_tracked_numbers: allTrackedNumbers,
944
- oldest_check_date: oldestCheckDate,
945
- raw: parsed.raw,
946
- },
947
- new_issues: newIssues,
948
- reopened_issues: reopenedIssues,
949
- recently_closed: recentlyClosed,
950
- search_errors: searchErrors,
951
- }, null, 2));
952
- }
953
-
954
- // ─── Command: fetch-issues ─────────────────────────────────────────────────
955
-
956
- async function cmdFetchIssues(flags) {
957
- const tempDir = flags['temp-dir'];
958
- const issuesFile = flags.issues;
959
-
960
- if (!tempDir || !issuesFile) {
961
- console.error('Usage: fetch-issues --temp-dir <dir> --issues <json-file>');
962
- process.exit(1);
963
- }
964
-
965
- if (!fs.existsSync(issuesFile)) {
966
- console.error(`Issues file not found: ${issuesFile}`);
967
- process.exit(1);
968
- }
969
-
970
- const issues = JSON.parse(fs.readFileSync(issuesFile, 'utf8'));
971
- const errors = [];
972
- const files = [];
973
-
974
- // Build all fetch tasks
975
- const allTasks = [];
976
- const taskMap = []; // Maps task index to { issueIdx, type }
977
-
978
- // Fetch raw JSON from gh api (no --jq — process in Node to avoid Windows quoting issues)
979
- function ghFetch(endpoint) {
980
- return execAsync(`gh api ${endpoint}`)
981
- .then(raw => JSON.parse(raw || '{}'))
982
- .catch(e => ({ _error: e.message }));
983
- }
984
-
985
- for (let i = 0; i < issues.length; i++) {
986
- const iss = issues[i];
987
- const { owner, repo, number, last_check_date, known_dupes, upstream } = iss;
988
- const issueEndpoint = `repos/${owner}/${repo}/issues/${number}`;
989
-
990
- // Issue data (single call — extract metadata + body + author in Node)
991
- allTasks.push(() => ghFetch(issueEndpoint));
992
- taskMap.push({ issueIdx: i, type: 'issue' });
993
-
994
- // Comments (all of them — filter by date in Node)
995
- allTasks.push(() => execAsync(`gh api ${issueEndpoint}/comments --paginate`)
996
- .then(raw => JSON.parse(raw || '[]'))
997
- .catch(e => ({ _error: e.message })));
998
- taskMap.push({ issueIdx: i, type: 'comments' });
999
-
1000
- // Known dupe state checks
1001
- const dupes = known_dupes || [];
1002
- for (const dupe of dupes) {
1003
- const dupeMatch = String(dupe).match(/(?:([^/]+)\/([^#]+))?#?(\d+)/);
1004
- if (dupeMatch) {
1005
- const dOwner = dupeMatch[1] || owner;
1006
- const dRepo = dupeMatch[2] || repo;
1007
- const dNumber = dupeMatch[3];
1008
- allTasks.push(() => ghFetch(`repos/${dOwner}/${dRepo}/issues/${dNumber}`));
1009
- taskMap.push({ issueIdx: i, type: 'dupe', dupeKey: `${dOwner}/${dRepo}#${dNumber}` });
1010
- }
1011
- }
1012
-
1013
- // Upstream check
1014
- if (upstream) {
1015
- const uMatch = String(upstream).match(/([^/]+)\/([^#]+)#(\d+)/);
1016
- if (uMatch) {
1017
- allTasks.push(() => ghFetch(`repos/${uMatch[1]}/${uMatch[2]}/issues/${uMatch[3]}`));
1018
- taskMap.push({ issueIdx: i, type: 'upstream' });
1019
- }
1020
- }
1021
- }
1022
-
1023
- // Run all tasks with batching (max 15 concurrent)
1024
- const results = await batchRun(allTasks, 15);
1025
-
1026
- // Organize results by issue — process raw API responses in Node
1027
- const issueData = issues.map(() => ({
1028
- metadata: null,
1029
- body: null,
1030
- comments: null,
1031
- dupe_states: {},
1032
- upstream_state: null,
1033
- cross_references: [],
1034
- urls: [],
1035
- }));
1036
-
1037
- for (let t = 0; t < results.length; t++) {
1038
- const { issueIdx, type, dupeKey } = taskMap[t];
1039
- const raw = results[t];
1040
-
1041
- switch (type) {
1042
- case 'issue': {
1043
- // Extract metadata and body from the single issue response
1044
- if (raw && !raw._error) {
1045
- issueData[issueIdx].metadata = {
1046
- state: raw.state,
1047
- labels: (raw.labels || []).map(l => l.name),
1048
- comments: raw.comments,
1049
- updated: raw.updated_at,
1050
- created: raw.created_at,
1051
- html_url: raw.html_url,
1052
- };
1053
- issueData[issueIdx].body = {
1054
- title: raw.title,
1055
- body: raw.body,
1056
- author: raw.user ? raw.user.login : null,
1057
- };
1058
- } else {
1059
- issueData[issueIdx].metadata = raw;
1060
- issueData[issueIdx].body = raw;
1061
- }
1062
- break;
1063
- }
1064
- case 'comments': {
1065
- // Filter comments by last_check_date in Node
1066
- const iss = issues[issueIdx];
1067
- if (Array.isArray(raw)) {
1068
- const filtered = iss.last_check_date
1069
- ? raw.filter(c => c.created_at > iss.last_check_date)
1070
- : raw;
1071
- issueData[issueIdx].comments = filtered.map(c => ({
1072
- author: c.user ? c.user.login : null,
1073
- date: c.created_at ? c.created_at.split('T')[0] : null,
1074
- body: c.body,
1075
- }));
1076
- } else {
1077
- issueData[issueIdx].comments = raw;
1078
- }
1079
- break;
1080
- }
1081
- case 'dupe': {
1082
- if (raw && !raw._error) {
1083
- issueData[issueIdx].dupe_states[dupeKey] = {
1084
- state: raw.state,
1085
- updated: raw.updated_at,
1086
- };
1087
- } else {
1088
- issueData[issueIdx].dupe_states[dupeKey] = raw;
1089
- }
1090
- break;
1091
- }
1092
- case 'upstream': {
1093
- if (raw && !raw._error) {
1094
- issueData[issueIdx].upstream_state = {
1095
- state: raw.state,
1096
- labels: (raw.labels || []).map(l => l.name),
1097
- updated: raw.updated_at,
1098
- };
1099
- } else {
1100
- issueData[issueIdx].upstream_state = raw;
1101
- }
1102
- break;
1103
- }
1104
- }
1105
- }
1106
-
1107
- // Post-process: extract cross_references and urls from body + comments
1108
- for (let i = 0; i < issues.length; i++) {
1109
- const data = issueData[i];
1110
- const allText = collectText(data);
1111
-
1112
- // Extract #NUMBER and owner/repo#NUMBER patterns
1113
- const crossRefs = new Set();
1114
- const refRe = /(?:([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+))?#(\d+)/g;
1115
- let m;
1116
- while ((m = refRe.exec(allText)) !== null) {
1117
- crossRefs.add(m[1] ? `${m[1]}#${m[2]}` : `#${m[2]}`);
1118
- }
1119
- data.cross_references = [...crossRefs];
1120
-
1121
- // Extract URLs
1122
- const urlSet = new Set();
1123
- const urlRe = /https?:\/\/[^\s)>\]"']+/g;
1124
- while ((m = urlRe.exec(allText)) !== null) {
1125
- urlSet.add(m[0].replace(/[.,;:]+$/, ''));
1126
- }
1127
- data.urls = [...urlSet];
1128
-
1129
- // Write raw JSON per issue
1130
- const iss = issues[i];
1131
- const outFile = path.join(tempDir, `raw-${iss.owner}-${iss.repo}-${iss.number}.json`);
1132
- try {
1133
- fs.writeFileSync(outFile, JSON.stringify(data, null, 2), 'utf8');
1134
- files.push(outFile);
1135
- } catch (err) {
1136
- errors.push(`Failed to write ${outFile}: ${err.message}`);
1137
- }
1138
- }
1139
-
1140
- console.log(JSON.stringify({ today: todayTag(), fetched: files.length, files, errors }));
1141
- }
1142
-
1143
- /**
1144
- * Collect all text content from issue data for cross-reference/URL extraction.
1145
- */
1146
- function collectText(data) {
1147
- const parts = [];
1148
- if (data.body) {
1149
- if (typeof data.body === 'string') parts.push(data.body);
1150
- else if (data.body.body) parts.push(data.body.body);
1151
- }
1152
- if (data.comments) {
1153
- if (typeof data.comments === 'string') parts.push(data.comments);
1154
- else if (Array.isArray(data.comments)) {
1155
- for (const c of data.comments) {
1156
- if (typeof c === 'string') parts.push(c);
1157
- else if (c && c.body) parts.push(c.body);
1158
- }
1159
- }
1160
- }
1161
- return parts.join('\n');
1162
- }
1163
-
1164
- // ─── Command: build-tracker ────────────────────────────────────────────────
1165
-
1166
- function cmdBuildTracker(flags) {
1167
- const tempDir = flags['temp-dir'];
1168
- const templatePath = flags.template;
1169
- const username = flags.username;
1170
- const trackerPath = flags.tracker;
1171
- const closedJsonPath = flags['closed-json'];
1172
- const date = flags.date || localDate();
1173
-
1174
- if (!tempDir || !templatePath || !username || !trackerPath) {
1175
- console.error('Usage: build-tracker --temp-dir <dir> --template <path> --username <name> --tracker <path> [--closed-json <path>]');
1176
- process.exit(1);
1177
- }
1178
-
1179
- // Step 1: Read template
1180
- if (!fs.existsSync(templatePath)) {
1181
- console.error(`Template not found: ${templatePath}`);
1182
- process.exit(1);
1183
- }
1184
- let tracker = fs.readFileSync(templatePath, 'utf8');
1185
-
1186
- // Step 2: Replace USERNAME_HERE
1187
- tracker = tracker.replace(/USERNAME_HERE/g, username);
1188
-
1189
- // Step 3: Read all issue-*.md result files from temp dir
1190
- if (!fs.existsSync(tempDir)) {
1191
- console.error(`Temp directory not found: ${tempDir}`);
1192
- process.exit(1);
1193
- }
1194
-
1195
- const resultFiles = fs.readdirSync(tempDir).filter(f =>
1196
- f.startsWith('issue-') && f.endsWith('.md')
1197
- );
1198
-
1199
- const entries = [];
1200
- for (const file of resultFiles) {
1201
- const content = fs.readFileSync(path.join(tempDir, file), 'utf8');
1202
- const { meta, body } = parseFrontmatter(content);
1203
- if (meta.type !== 'issue') continue;
1204
-
1205
- // Build tracker entry from result file
1206
- const entry = buildTrackerEntry(meta, body, date);
1207
- entries.push(entry);
1208
- }
1209
-
1210
- // Step 4: Insert active entries into tracker
1211
- const activeEntriesText = entries.join('\n');
1212
- tracker = tracker.replace(
1213
- /(## Active Issues[^\n]*\n)([\s\S]*?)(## Closed)/,
1214
- `$1\n${activeEntriesText}\n$3`
1215
- );
1216
-
1217
- // Step 5: Handle closed issues if provided
1218
- let closedCount = 0;
1219
- if (closedJsonPath && fs.existsSync(closedJsonPath)) {
1220
- const closedIssues = JSON.parse(fs.readFileSync(closedJsonPath, 'utf8'));
1221
- closedCount = closedIssues.length;
1222
- let closedText = '';
1223
- for (const ci of closedIssues) {
1224
- closedText += `### ${ci.owner}/${ci.repo}#${ci.number} — ${ci.title}\n`;
1225
- closedText += `- ${ci.resolution || 'Closed.'}\n\n`;
1226
- }
1227
- if (closedText) {
1228
- tracker = tracker.replace(
1229
- /(## Closed[^\n]*\n)/,
1230
- `$1\n${closedText}`
1231
- );
1232
- }
1233
- }
1234
-
1235
- // Step 6: Write tracker
1236
- fs.writeFileSync(trackerPath, tracker, 'utf8');
1237
-
1238
- console.log(JSON.stringify({
1239
- today: todayTag(),
1240
- written: true,
1241
- path: trackerPath,
1242
- active_count: entries.length,
1243
- closed_count: closedCount,
1244
- }));
1245
- }
1246
-
1247
- /**
1248
- * Build a tracker entry string from result file frontmatter and body.
1249
- */
1250
- function buildTrackerEntry(meta, body, date) {
1251
- const lines = [];
1252
- lines.push(`### ${meta.owner}/${meta.repo}#${meta.number} — ${meta.title}`);
1253
-
1254
- // Goal — from tracker updates or frontmatter
1255
- const trackerUpdatesForGoal = extractSection(body, 'Tracker Updates') || '';
1256
- const goalLine = trackerUpdatesForGoal.match(/^goal:\s*(.+)/m);
1257
- if (goalLine) {
1258
- lines.push(`- **Goal:** ${goalLine[1].trim()}`);
1259
- }
1260
-
1261
- // Role
1262
- lines.push(`- **Role:** ${meta.role || 'Unknown'}`);
1263
-
1264
- // Filed
1265
- if (meta.filed) {
1266
- lines.push(`- **Filed:** ${meta.filed}`);
1267
- }
1268
-
1269
- // Status — from ## Status Summary + labels
1270
- const statusSummary = extractSection(body, 'Status Summary') || 'Open.';
1271
- const labels = meta.labels
1272
- ? (Array.isArray(meta.labels) ? meta.labels.join(', ') : meta.labels)
1273
- : '';
1274
- const trackerUpdates = extractSection(body, 'Tracker Updates') || '';
1275
- const statusLine = trackerUpdates.match(/^status_summary:\s*(.+)/m);
1276
- const statusText = statusLine
1277
- ? statusLine[1].trim()
1278
- : `${meta.state || 'Open'}. Labels: ${labels}. ${statusSummary.split('\n')[0]}`;
1279
- const dateStr = date || localDate();
1280
- lines.push(`- **Status as of ${dateStr}:** ${statusText}`);
1281
-
1282
- // What to check — from ## Watch For or tracker updates
1283
- const watchLine = trackerUpdates.match(/^what_to_check:\s*(.+)/m);
1284
- const watchFor = watchLine
1285
- ? watchLine[1].trim()
1286
- : (extractSection(body, 'Watch For') || 'Monitor for updates.').split('\n')[0].replace(/^-\s*/, '');
1287
- lines.push(`- **What to check:** ${watchFor}`);
1288
-
1289
- // Related — from ## Cross-References
1290
- const crossRefs = extractSection(body, 'Cross-References');
1291
- if (crossRefs && crossRefs.trim() !== 'None' && crossRefs.trim() !== 'None found.') {
1292
- lines.push(`- **Related:** ${crossRefs.split('\n')[0].trim()}`);
1293
- }
1294
-
1295
- // Upstream
1296
- const upstream = extractSection(body, 'Upstream');
1297
- if (upstream && upstream.trim() !== 'N/A' && upstream.trim() !== 'None') {
1298
- lines.push(`- **Upstream:** ${upstream.split('\n')[0].trim()}`);
1299
- }
1300
-
1301
- // Key Context -> workaround / future
1302
- const keyContext = extractSection(body, 'Key Context');
1303
- if (keyContext && keyContext.trim() !== 'N/A') {
1304
- // Check for workaround
1305
- const workaroundMatch = keyContext.match(/[Ww]orkaround:?\s*(.+)/);
1306
- if (workaroundMatch) {
1307
- lines.push(`- **Workaround:** ${workaroundMatch[1].trim()}`);
1308
- }
1309
- }
1310
-
1311
- // Duplicates from ## Duplicates and Related
1312
- const dupeSection = extractSection(body, 'Duplicates and Related')
1313
- || extractSection(body, 'Known');
1314
- if (dupeSection) {
1315
- const dupeLines = dupeSection.split('\n').filter(l => l.match(/^\s*-?\s*[#*]/));
1316
- if (dupeLines.length > 0) {
1317
- lines.push(`- **Duplicates found (${dateStr}):**`);
1318
- for (const dl of dupeLines) {
1319
- const cleaned = dl.replace(/^[\s-]*/, ' - ');
1320
- lines.push(cleaned);
1321
- }
1322
- }
1323
- }
1324
-
1325
- // Next steps
1326
- const nextSteps = extractSection(body, 'Next Steps');
1327
- if (nextSteps && nextSteps.trim() !== 'None' && nextSteps.trim() !== 'None — no action needed.') {
1328
- lines.push(`- **Next steps (now):** ${nextSteps.split('\n')[0].replace(/^-\s*\[.\]\s*/, '').trim()}`);
1329
- } else {
1330
- lines.push(`- **Next steps (now):** None — no action needed.`);
1331
- }
1332
-
1333
- // Future — from Key Context
1334
- if (keyContext && keyContext.trim() !== 'N/A') {
1335
- lines.push(`- **Future:** ${keyContext.split('\n')[0].replace(/^-\s*/, '').trim()}`);
1336
- }
1337
-
1338
- // History — from history_entry lines in Tracker Updates
1339
- const historyEntries = [];
1340
- if (trackerUpdates) {
1341
- const historyRe = /^history_entry:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*(.+)/gm;
1342
- let hm;
1343
- while ((hm = historyRe.exec(trackerUpdates)) !== null) {
1344
- historyEntries.push(` - **${hm[1]}:** ${hm[2].trim()}`);
1345
- }
1346
- }
1347
- if (historyEntries.length === 0) {
1348
- // Default entry on initial build
1349
- historyEntries.push(` - **${dateStr}:** Added to tracker`);
1350
- }
1351
- lines.push(`- **History:**`);
1352
- for (const he of historyEntries) {
1353
- lines.push(he);
1354
- }
1355
-
1356
- lines.push('');
1357
- return lines.join('\n');
1358
- }
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tracker-tools.cjs — Data service layer for estack-github-issue-tracker skill.
5
+ *
6
+ * Commands:
7
+ * startup --tracker <path> Auth, parse tracker + config, create temp dir, discover issues
8
+ * fetch-issues --temp-dir <dir> --issues <json-file> Parallel gh api fetches per issue
9
+ * compile-report --temp-dir <dir> --date <YYYY-MM-DD> Build overview from result files
10
+ * update-tracker --tracker <path> --temp-dir <dir> --date <YYYY-MM-DD> Apply changes (incl. Goal)
11
+ * build-tracker --temp-dir <dir> --template <path> --username <name> --tracker <path> First-run tracker creation
12
+ *
13
+ * All commands return a `today` field with the current date.
14
+ * Zero dependencies. Deterministic output. The agent calls this; it cannot "forget".
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+ const { exec, execSync } = require('child_process');
23
+
24
+ function localDate() {
25
+ const now = new Date();
26
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
27
+ }
28
+
29
+ function todayTag() {
30
+ return `today's date is **${localDate()}**, ignore earlier dates`;
31
+ }
32
+
33
+ // ─── CLI Router ─────────────────────────────────────────────────────────────
34
+
35
+ const [,, command, ...rawArgs] = process.argv;
36
+
37
+ const commands = {
38
+ 'compile-report': cmdCompileReport,
39
+ 'update-tracker': cmdUpdateTracker,
40
+ 'startup': cmdStartup,
41
+ 'fetch-issues': cmdFetchIssues,
42
+ 'build-tracker': cmdBuildTracker,
43
+ };
44
+
45
+ if (!command || !commands[command]) {
46
+ console.error(`Usage: tracker-tools.cjs <command> [options]`);
47
+ console.error(`Commands: ${Object.keys(commands).join(', ')}`);
48
+ process.exit(1);
49
+ }
50
+
51
+ // Parse --flag value pairs from rawArgs
52
+ function parseFlags(args) {
53
+ const flags = {};
54
+ for (let i = 0; i < args.length; i++) {
55
+ if (args[i].startsWith('--')) {
56
+ const key = args[i].slice(2);
57
+ const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true;
58
+ flags[key] = val;
59
+ } else if (!flags._positional) {
60
+ flags._positional = args[i];
61
+ }
62
+ }
63
+ return flags;
64
+ }
65
+
66
+ const flags = parseFlags(rawArgs);
67
+
68
+ (async () => {
69
+ try {
70
+ await commands[command](flags);
71
+ } catch (err) {
72
+ console.error(`Error in ${command}: ${err.message}`);
73
+ process.exit(1);
74
+ }
75
+ })();
76
+
77
+ // ─── Frontmatter Parser ────────────────────────────────────────────────────
78
+
79
+ function parseFrontmatter(content) {
80
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
81
+ if (!match) return { meta: {}, body: content };
82
+
83
+ const meta = {};
84
+ const lines = match[1].split(/\r?\n/);
85
+ for (const line of lines) {
86
+ const idx = line.indexOf(':');
87
+ if (idx === -1) continue;
88
+ const key = line.slice(0, idx).trim();
89
+ let val = line.slice(idx + 1).trim();
90
+
91
+ // Boolean
92
+ if (val === 'true') val = true;
93
+ else if (val === 'false') val = false;
94
+ // Number
95
+ else if (/^\d+$/.test(val)) val = parseInt(val, 10);
96
+ // Comma-separated list
97
+ else if (val.includes(',') && !val.startsWith('"')) {
98
+ val = val.split(',').map(s => s.trim()).filter(Boolean);
99
+ }
100
+ // Strip surrounding quotes
101
+ else if (val.startsWith('"') && val.endsWith('"')) {
102
+ val = val.slice(1, -1);
103
+ }
104
+
105
+ meta[key] = val;
106
+ }
107
+
108
+ const body = content.slice(match[0].length).trim();
109
+ return { meta, body };
110
+ }
111
+
112
+ // ─── Tracker Parser ─────────────────────────────────────────────────────────
113
+
114
+ function parseTrackerFile(content) {
115
+ const result = {
116
+ username: null,
117
+ config: null,
118
+ active_issues: [],
119
+ closed_issues: [],
120
+ raw: content,
121
+ };
122
+
123
+ // Extract username
124
+ const userMatch = content.match(/GitHub username:\s*\*\*(.+?)\*\*/);
125
+ if (userMatch) result.username = userMatch[1];
126
+
127
+ // Extract Config section (between ## Config and next --- or ##)
128
+ const configMatch = content.match(/## Config\s*\n([\s\S]*?)(?=\n---|\n## (?!Config))/);
129
+ if (configMatch) {
130
+ // Strip HTML comments and trim
131
+ result.config = configMatch[1].replace(/<!--[\s\S]*?-->/g, '').trim() || null;
132
+ }
133
+
134
+ // Split into Active and Closed sections
135
+ const activeSectionMatch = content.match(
136
+ /## Active Issues[^\n]*\n([\s\S]*?)(?=\n## (?!Active)|$)/
137
+ );
138
+ const closedSectionMatch = content.match(
139
+ /## Closed[^\n]*\n([\s\S]*?)(?=\n## (?!Closed)|$)/
140
+ );
141
+
142
+ if (activeSectionMatch) {
143
+ result.active_issues = parseIssueSections(activeSectionMatch[1]);
144
+ }
145
+ if (closedSectionMatch) {
146
+ result.closed_issues = parseClosedSections(closedSectionMatch[1]);
147
+ }
148
+
149
+ return result;
150
+ }
151
+
152
+ function parseIssueSections(sectionContent) {
153
+ const issues = [];
154
+ // Split by ### headers
155
+ const parts = sectionContent.split(/(?=^### )/m).filter(p => p.trim());
156
+
157
+ for (const part of parts) {
158
+ // Skip HTML comments
159
+ if (part.trim().startsWith('<!--')) continue;
160
+
161
+ const issue = parseIssueBlock(part);
162
+ if (issue) issues.push(issue);
163
+ }
164
+ return issues;
165
+ }
166
+
167
+ function parseIssueBlock(block) {
168
+ // Parse header: ### owner/repo#NUMBER — Title
169
+ const headerMatch = block.match(
170
+ /^### ([^/]+)\/([^#]+)#(\d+)\s*[—–-]\s*(.+)/m
171
+ );
172
+ if (!headerMatch) return null;
173
+
174
+ const issue = {
175
+ owner: headerMatch[1].trim(),
176
+ repo: headerMatch[2].trim(),
177
+ number: parseInt(headerMatch[3], 10),
178
+ title: headerMatch[4].trim(),
179
+ goal: extractField(block, 'Goal'),
180
+ role: extractField(block, 'Role'),
181
+ filed: extractField(block, 'Filed'),
182
+ last_check_date: null,
183
+ status_summary: extractField(block, 'Status as of'),
184
+ what_to_check: extractField(block, 'What to check'),
185
+ related: extractListField(block, 'Related'),
186
+ upstream: extractUpstream(block),
187
+ duplicates: extractNestedItems(block, 'Duplicates found'),
188
+ adjacent: extractNestedItems(block, 'Adjacent issues found'),
189
+ next_steps: extractField(block, 'Next steps \\(now\\)'),
190
+ future: extractField(block, 'Future'),
191
+ };
192
+
193
+ // Extract last check date from "Status as of YYYY-MM-DD:"
194
+ const dateMatch = block.match(/\*\*Status as of (\d{4}-\d{2}-\d{2}):\*\*/);
195
+ if (dateMatch) issue.last_check_date = dateMatch[1];
196
+
197
+ // Extract History field (multi-line: bullet list under **History:**)
198
+ issue.history = extractHistoryField(block);
199
+
200
+ return issue;
201
+ }
202
+
203
+ function extractField(block, fieldName) {
204
+ // Special case: "Status as of YYYY-MM-DD:" has the date embedded in the bold
205
+ if (fieldName === 'Status as of') {
206
+ const statusRe = /\*\*Status as of \d{4}-\d{2}-\d{2}:\*\*\s*(.+)/i;
207
+ const statusMatch = block.match(statusRe);
208
+ return statusMatch ? statusMatch[1].trim() : null;
209
+ }
210
+ const re = new RegExp(`\\*\\*${fieldName}(?:\\s+\\S+)?[.:]*\\*\\*\\s*(.+)`, 'i');
211
+ const match = block.match(re);
212
+ return match ? match[1].trim() : null;
213
+ }
214
+
215
+ function extractListField(block, fieldName) {
216
+ const val = extractField(block, fieldName);
217
+ if (!val) return [];
218
+ return val.split(',').map(s => s.trim()).filter(Boolean);
219
+ }
220
+
221
+ function extractUpstream(block) {
222
+ const val = extractField(block, 'Upstream');
223
+ if (!val || val.toLowerCase() === 'n/a' || val === 'none') return null;
224
+ const match = val.match(/([^/]+)\/([^#]+)#(\d+)/);
225
+ if (!match) return null;
226
+ return { owner: match[1], repo: match[2], number: parseInt(match[3], 10) };
227
+ }
228
+
229
+ function extractNestedItems(block, sectionName) {
230
+ const items = [];
231
+ const re = new RegExp(`\\*\\*${sectionName}[^*]*\\*\\*[:\\s]*\\n([\\s\\S]*?)(?=\\n- \\*\\*[A-Z]|$)`, 'i');
232
+ const match = block.match(re);
233
+ if (!match) return items;
234
+
235
+ const lines = match[1].split(/\r?\n/);
236
+ for (const line of lines) {
237
+ const itemMatch = line.match(/^\s+-\s+\*\*#?(\d+)\*\*\s*[—–-]\s*(.+)/);
238
+ if (itemMatch) {
239
+ items.push({
240
+ number: parseInt(itemMatch[1], 10),
241
+ detail: itemMatch[2].trim(),
242
+ });
243
+ }
244
+ }
245
+ return items;
246
+ }
247
+
248
+ /**
249
+ * Extract history entries from an issue block.
250
+ * Matches all " - **YYYY-MM-DD:** ..." lines under "**History:**".
251
+ * Returns array of strings like "**2026-03-15:** Filed issue".
252
+ */
253
+ function extractHistoryField(block) {
254
+ // Find **History:** and collect indented bullet lines until next top-level field or section
255
+ const historyStart = block.search(/- \*\*History:\*\*/);
256
+ if (historyStart === -1) return [];
257
+
258
+ const afterHistory = block.slice(historyStart);
259
+ // Collect lines after the **History:** line that match date bullets
260
+ const lines = afterHistory.split(/\r?\n/);
261
+ const entries = [];
262
+ let started = false;
263
+ for (const line of lines) {
264
+ if (!started) {
265
+ if (/- \*\*History:\*\*/.test(line)) { started = true; }
266
+ continue;
267
+ }
268
+ // Match indented date bullets: " - **YYYY-MM-DD:** ..."
269
+ const bullet = line.match(/^\s+-\s+(\*\*\d{4}-\d{2}-\d{2}:\*\*.+)/);
270
+ if (bullet) {
271
+ entries.push(bullet[1].trim());
272
+ } else if (line.match(/^- \*\*[A-Z]/) || line.match(/^### /)) {
273
+ // Next top-level field or section header — stop
274
+ break;
275
+ }
276
+ }
277
+ return entries;
278
+ }
279
+
280
+ function parseClosedSections(sectionContent) {
281
+ const issues = [];
282
+ const parts = sectionContent.split(/(?=^### )/m).filter(p => p.trim());
283
+
284
+ for (const part of parts) {
285
+ if (part.trim().startsWith('<!--')) continue;
286
+ const headerMatch = part.match(
287
+ /^### ([^/]+)\/([^#]+)#(\d+)\s*[—–-]\s*(.+)/m
288
+ );
289
+ if (!headerMatch) continue;
290
+
291
+ const lines = part.split(/\r?\n/).slice(1).filter(l => l.trim().startsWith('-'));
292
+ issues.push({
293
+ owner: headerMatch[1].trim(),
294
+ repo: headerMatch[2].trim(),
295
+ number: parseInt(headerMatch[3], 10),
296
+ title: headerMatch[4].trim(),
297
+ resolution: lines.map(l => l.replace(/^-\s*/, '').trim()).join(' '),
298
+ });
299
+ }
300
+ return issues;
301
+ }
302
+
303
+ // ─── Report Compiler ────────────────────────────────────────────────────────
304
+
305
+ function compileOverviewReport(tempDir, date) {
306
+ const files = fs.readdirSync(tempDir).filter(f => f.endsWith('.md'));
307
+ if (files.length === 0) {
308
+ return { error: 'No result files found in temp directory' };
309
+ }
310
+
311
+ const issueResults = [];
312
+
313
+ for (const file of files) {
314
+ const content = fs.readFileSync(path.join(tempDir, file), 'utf8');
315
+ const { meta, body } = parseFrontmatter(content);
316
+
317
+ if (meta.type === 'issue') {
318
+ issueResults.push({ meta, body, file });
319
+ }
320
+ }
321
+
322
+ // Sort: activity first, then by update recency
323
+ const withActivity = issueResults.filter(r => r.meta.has_activity === true);
324
+ const noActivity = issueResults.filter(r => r.meta.has_activity !== true);
325
+
326
+ // Count repos
327
+ const repos = new Set(issueResults.map(r => `${r.meta.owner}/${r.meta.repo}`));
328
+
329
+ // Build report
330
+ let report = '';
331
+
332
+ report += `## GitHub Issues Check-In — ${date}\n\n`;
333
+ report += `**Tracking ${issueResults.length} active issues across ${repos.size} repos**`;
334
+
335
+ // Find oldest check date for "last check"
336
+ const checkDates = issueResults
337
+ .map(r => r.meta.last_check_date)
338
+ .filter(Boolean)
339
+ .sort();
340
+ if (checkDates.length > 0) {
341
+ report += ` | Last check: ${checkDates[0]}`;
342
+ }
343
+ report += '\n\n---\n\n';
344
+
345
+ // ── Issues with Activity ──
346
+ if (withActivity.length > 0) {
347
+ report += '### Issues with Activity\n\n';
348
+ report += `These ${withActivity.length} issue${withActivity.length === 1 ? ' has' : 's have'} new comments or state changes since your last check.\n\n`;
349
+ for (const r of withActivity) {
350
+ report += buildIssueDetailBlock(r);
351
+ }
352
+ }
353
+
354
+ // ── No Activity ──
355
+ if (noActivity.length > 0) {
356
+ report += '### No Activity\n\n';
357
+ for (const r of noActivity) {
358
+ report += buildQuietIssueBlock(r);
359
+ }
360
+ }
361
+
362
+ // ── Upstream Status ──
363
+ const upstreamResults = issueResults.filter(r => {
364
+ const upstreamSection = extractSection(r.body, 'Upstream');
365
+ return upstreamSection && upstreamSection.trim() !== 'N/A' && upstreamSection.trim() !== 'None';
366
+ });
367
+ if (upstreamResults.length > 0) {
368
+ report += '### Upstream Status\n\n';
369
+ report += '| Upstream issue | State | Impact |\n';
370
+ report += '|----------------|-------|--------|\n';
371
+ for (const r of upstreamResults) {
372
+ const section = extractSection(r.body, 'Upstream');
373
+ report += `| ${r.meta.owner}/${r.meta.repo}#${r.meta.number} | ${section.trim()} |\n`;
374
+ }
375
+ report += '\n---\n\n';
376
+ }
377
+
378
+ // ── Summary ──
379
+ const actionItems = withActivity.reduce((count, r) => {
380
+ const steps = extractSection(r.body, 'Next Steps');
381
+ if (steps) count += (steps.match(/^- \[/gm) || []).length;
382
+ return count;
383
+ }, 0);
384
+
385
+ report += '### Summary\n\n';
386
+ report += `**Action items:** ${actionItems}\n`;
387
+ if (withActivity.length > 0) {
388
+ const needAttention = withActivity.slice(0, 3).map(
389
+ r => `${r.meta.owner}/${r.meta.repo}#${r.meta.number}`
390
+ );
391
+ report += `**Issues needing attention:** ${needAttention.join(', ')}\n`;
392
+ }
393
+ if (noActivity.length > 0) {
394
+ report += `**All quiet:** ${noActivity.length} issues with no activity\n`;
395
+ }
396
+
397
+ return { report, issue_count: issueResults.length, activity_count: withActivity.length };
398
+ }
399
+
400
+ function buildIssueDetailBlock(r) {
401
+ let block = '';
402
+ block += `#### ${r.meta.owner}/${r.meta.repo}#${r.meta.number} — ${r.meta.title}\n`;
403
+ block += `| Field | Value |\n`;
404
+ block += `|-------|-------|\n`;
405
+ block += `| **State** | ${r.meta.state || 'Open'}${r.meta.state_changed ? ' [changed]' : ''} |\n`;
406
+ if (r.meta.labels) {
407
+ const labels = Array.isArray(r.meta.labels) ? r.meta.labels.join(', ') : r.meta.labels;
408
+ block += `| **Labels** | ${labels} |\n`;
409
+ }
410
+ block += `| **Your role** | ${r.meta.role || 'Unknown'} |\n\n`;
411
+
412
+ // Plain English summary of where this issue stands
413
+ const summary = extractSection(r.body, 'Status Summary');
414
+ if (summary) {
415
+ block += `${summary.trim()}\n\n`;
416
+ }
417
+
418
+ // Activity section — what changed since last check
419
+ const activity = extractSection(r.body, 'Activity');
420
+ if (activity) {
421
+ block += `**What changed:**\n${activity}\n\n`;
422
+ }
423
+
424
+ // Duplicates
425
+ const dupes = extractSection(r.body, 'Known');
426
+ const newFinds = extractSection(r.body, 'New finds');
427
+ const hasDupes = dupes && !/^(none|no changes)\.?$/i.test(dupes.trim());
428
+ const hasNewFinds = newFinds && !/^(none|none found|skipped)\.?/i.test(newFinds.trim());
429
+ if (hasDupes || hasNewFinds) {
430
+ block += `**Duplicates & related:**\n`;
431
+ if (hasDupes) block += `- Known dupes — ${dupes.trim()}\n`;
432
+ if (hasNewFinds) block += `- New finds — ${newFinds.trim()}\n`;
433
+ block += '\n';
434
+ }
435
+
436
+ // What you need to do next
437
+ const steps = extractSection(r.body, 'Next Steps');
438
+ if (steps) {
439
+ block += `**What to do next:**\n${steps}\n\n`;
440
+ }
441
+
442
+ // Watch For
443
+ const watch = extractSection(r.body, 'Watch For');
444
+ if (watch) {
445
+ block += `**Watch for:**\n${watch}\n\n`;
446
+ }
447
+
448
+ block += '---\n\n';
449
+ return block;
450
+ }
451
+
452
+ function buildQuietIssueBlock(r) {
453
+ let block = '';
454
+ block += `#### ${r.meta.owner}/${r.meta.repo}#${r.meta.number} — ${r.meta.title}\n`;
455
+ const lastDate = r.meta.last_comment_date || r.meta.last_check_date || 'unknown';
456
+ block += `- Last activity: ${lastDate}\n`;
457
+
458
+ // Keep quiet issues compact to avoid report bloat.
459
+ const summary = extractSection(r.body, 'Status Summary');
460
+ if (summary) {
461
+ block += `- Status: ${firstLine(summary)}\n`;
462
+ }
463
+
464
+ const steps = extractSection(r.body, 'Next Steps');
465
+ if (steps) {
466
+ block += `- Next: ${firstLine(steps)}\n`;
467
+ }
468
+
469
+ const watch = extractSection(r.body, 'Watch For');
470
+ if (watch) {
471
+ block += `- Watch: ${firstLine(watch)}\n`;
472
+ }
473
+
474
+ block += '\n';
475
+ return block;
476
+ }
477
+
478
+ function extractSection(body, headerPattern) {
479
+ // Match ## Header or ### Header, capture until next ## or end
480
+ const re = new RegExp(
481
+ `^#{2,4}\\s+(?:[^\\n]*?${headerPattern}[^\\n]*)\\n([\\s\\S]*?)(?=^#{2,4}\\s|$)`,
482
+ 'mi'
483
+ );
484
+ const match = body.match(re);
485
+ return match ? match[1].trim() : null;
486
+ }
487
+
488
+ function firstLine(text) {
489
+ if (!text) return '';
490
+ const line = text.split(/\r?\n/).find(l => l.trim().length > 0) || '';
491
+ return line.replace(/^\s*[-*]\s*/, '').trim();
492
+ }
493
+
494
+ // ─── Tracker Updater ────────────────────────────────────────────────────────
495
+
496
+ function applyTrackerUpdates(trackerContent, tempDir, date) {
497
+ const files = fs.readdirSync(tempDir).filter(f => f.endsWith('.md'));
498
+ let updated = trackerContent;
499
+ const changes = [];
500
+
501
+ for (const file of files) {
502
+ const content = fs.readFileSync(path.join(tempDir, file), 'utf8');
503
+ const { meta, body } = parseFrontmatter(content);
504
+
505
+ if (meta.type !== 'issue') continue;
506
+
507
+ const issueKey = `${meta.owner}/${meta.repo}#${meta.number}`;
508
+
509
+ // Find this issue's section in the tracker
510
+ const sectionRe = new RegExp(
511
+ `(### ${escapeRegex(meta.owner)}/${escapeRegex(meta.repo)}#${meta.number}\\s*[—–-][^\\n]*\\n[\\s\\S]*?)(?=\\n### |\\n## |$)`,
512
+ 'm'
513
+ );
514
+ const sectionMatch = updated.match(sectionRe);
515
+ if (!sectionMatch) continue;
516
+
517
+ let section = sectionMatch[1];
518
+ const originalSection = section;
519
+
520
+ // Update "Status as of" date and summary
521
+ const trackerUpdates = extractSection(body, 'Tracker Updates');
522
+ if (trackerUpdates) {
523
+ const statusLine = trackerUpdates.match(/^status_summary:\s*(.+)/m);
524
+ if (statusLine) {
525
+ section = section.replace(
526
+ /\*\*Status as of \d{4}-\d{2}-\d{2}:\*\*\s*.+/,
527
+ `**Status as of ${date}:** ${statusLine[1].trim()}`
528
+ );
529
+ }
530
+
531
+ const watchLine = trackerUpdates.match(/^what_to_check:\s*(.+)/m);
532
+ if (watchLine) {
533
+ section = section.replace(
534
+ /\*\*What to check\*\*:\s*.+/,
535
+ `**What to check:** ${watchLine[1].trim()}`
536
+ );
537
+ }
538
+
539
+ // Update or add Goal
540
+ const goalLine = trackerUpdates.match(/^goal:\s*(.+)/m);
541
+ if (goalLine) {
542
+ if (section.includes('**Goal:**')) {
543
+ section = section.replace(
544
+ /\*\*Goal:\*\*\s*.+/,
545
+ `**Goal:** ${goalLine[1].trim()}`
546
+ );
547
+ } else {
548
+ // Insert Goal as first field after the header line
549
+ section = section.replace(
550
+ /(### [^\n]+\n)/,
551
+ `$1- **Goal:** ${goalLine[1].trim()}\n`
552
+ );
553
+ }
554
+ changes.push(`Set Goal for ${issueKey}`);
555
+ }
556
+ }
557
+
558
+ // Handle state change: open → closed (move to Closed section)
559
+ if (meta.state === 'closed' && meta.state_changed) {
560
+ // Preserve key fields from the original section before removing it
561
+ const closedLines = [];
562
+ closedLines.push(`### ${issueKey} — ${meta.title}`);
563
+ closedLines.push(`- Closed as of ${date}. ${meta.close_reason || ''}`);
564
+
565
+ // Preserve Goal
566
+ const goalMatch = originalSection.match(/- \*\*Goal:\*\*\s*(.+)/);
567
+ if (goalMatch) closedLines.push(`- **Goal:** ${goalMatch[1].trim()}`);
568
+
569
+ // Preserve Role
570
+ const roleMatch = originalSection.match(/- \*\*Role:\*\*\s*(.+)/);
571
+ if (roleMatch) closedLines.push(`- **Role:** ${roleMatch[1].trim()}`);
572
+
573
+ // Preserve History section
574
+ const historyIdx = originalSection.indexOf('- **History:**');
575
+ if (historyIdx !== -1) {
576
+ const historyBlock = originalSection.slice(historyIdx);
577
+ const historyLines = historyBlock.split(/\r?\n/);
578
+ const preserved = [historyLines[0]]; // "- **History:**"
579
+ for (let hi = 1; hi < historyLines.length; hi++) {
580
+ if (/^\s+-\s+\*\*\d{4}-\d{2}-\d{2}:\*\*/.test(historyLines[hi])) {
581
+ preserved.push(historyLines[hi]);
582
+ } else if (/^- \*\*[A-Z]/.test(historyLines[hi]) || /^### /.test(historyLines[hi])) {
583
+ break;
584
+ }
585
+ }
586
+ // Add closing history entry
587
+ preserved.push(` - **${date}:** Closed. ${meta.close_reason || ''}`);
588
+ closedLines.push(...preserved);
589
+ }
590
+
591
+ const closedEntry = closedLines.join('\n') + '\n\n';
592
+
593
+ // Remove from active
594
+ updated = updated.replace(originalSection, '');
595
+ // Add to closed
596
+ updated = updated.replace(
597
+ /(## Closed[^\n]*\n)/,
598
+ `$1\n${closedEntry}`
599
+ );
600
+ changes.push(`Moved ${issueKey} to Closed`);
601
+ continue;
602
+ }
603
+
604
+ // Add new duplicates
605
+ if (trackerUpdates) {
606
+ const newDupeLines = [];
607
+ const dupeRe = /new_duplicate:\s*#?(\d+)\s*[—–-]\s*(.+)/gm;
608
+ let dupeMatch;
609
+ while ((dupeMatch = dupeRe.exec(trackerUpdates)) !== null) {
610
+ newDupeLines.push(` - **#${dupeMatch[1]}** — ${dupeMatch[2].trim()}`);
611
+ }
612
+ if (newDupeLines.length > 0) {
613
+ const dupeHeader = `**Duplicates found (${date}):**`;
614
+ if (section.includes('Duplicates found')) {
615
+ // Append to existing duplicates section
616
+ section = section.replace(
617
+ /(Duplicates found[^*]*\*\*:?\s*\n(?:\s+-[^\n]*\n)*)/,
618
+ `$1${dupeHeader}\n${newDupeLines.join('\n')}\n`
619
+ );
620
+ } else {
621
+ // Add before Next steps
622
+ section = section.replace(
623
+ /(\*\*Next steps)/,
624
+ `- ${dupeHeader}\n${newDupeLines.join('\n')}\n- $1`
625
+ );
626
+ }
627
+ changes.push(`Added ${newDupeLines.length} duplicates to ${issueKey}`);
628
+ }
629
+ }
630
+
631
+ // Append history entries from result file
632
+ if (trackerUpdates) {
633
+ const newHistoryEntries = [];
634
+ const historyRe = /^history_entry:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*(.+)/gm;
635
+ let hm;
636
+ while ((hm = historyRe.exec(trackerUpdates)) !== null) {
637
+ newHistoryEntries.push({ date: hm[1], desc: hm[2].trim() });
638
+ }
639
+
640
+ if (newHistoryEntries.length > 0) {
641
+ const historyHeaderIdx = section.indexOf('- **History:**');
642
+ if (historyHeaderIdx !== -1) {
643
+ // History section exists — collect existing entries for dedup
644
+ const afterHeader = section.slice(historyHeaderIdx);
645
+ const existingLines = afterHeader.split(/\r?\n/);
646
+ const existingTexts = new Set();
647
+ for (const line of existingLines.slice(1)) {
648
+ const m = line.match(/^\s+-\s+\*\*(\d{4}-\d{2}-\d{2}):\*\*\s*(.+)/);
649
+ if (m) existingTexts.add(`${m[1]}|${m[2].trim()}`);
650
+ else if (line.match(/^- \*\*[A-Z]/) || line.match(/^### /)) break;
651
+ }
652
+
653
+ // Build lines to append (deduped)
654
+ const toAppend = newHistoryEntries
655
+ .filter(e => !existingTexts.has(`${e.date}|${e.desc}`))
656
+ .map(e => ` - **${e.date}:** ${e.desc}`);
657
+
658
+ if (toAppend.length > 0) {
659
+ // Find the last history bullet line position and insert after it
660
+ // We'll insert the new lines before the next top-level field after **History:**
661
+ const historyBlock = section.slice(historyHeaderIdx);
662
+ const historyLines = historyBlock.split(/\r?\n/);
663
+ let lastBulletLine = 0;
664
+ for (let li = 1; li < historyLines.length; li++) {
665
+ if (/^\s+-\s+\*\*\d{4}-\d{2}-\d{2}:\*\*/.test(historyLines[li])) {
666
+ lastBulletLine = li;
667
+ } else if (/^- \*\*[A-Z]/.test(historyLines[li]) || /^### /.test(historyLines[li])) {
668
+ break;
669
+ }
670
+ }
671
+ // Insert toAppend after lastBulletLine
672
+ historyLines.splice(lastBulletLine + 1, 0, ...toAppend);
673
+ const newHistoryBlock = historyLines.join('\n');
674
+ section = section.slice(0, historyHeaderIdx) + newHistoryBlock;
675
+ changes.push(`Appended ${toAppend.length} history entries to ${issueKey}`);
676
+ }
677
+ } else {
678
+ // No history section yet — insert before trailing newline / end of section
679
+ const insertPoint = section.lastIndexOf('\n');
680
+ const historyLines = ['- **History:**'];
681
+ for (const e of newHistoryEntries) {
682
+ historyLines.push(` - **${e.date}:** ${e.desc}`);
683
+ }
684
+ section = section.slice(0, insertPoint) + '\n' + historyLines.join('\n') + section.slice(insertPoint);
685
+ changes.push(`Added History section with ${newHistoryEntries.length} entries to ${issueKey}`);
686
+ }
687
+ }
688
+ }
689
+
690
+ if (section !== originalSection) {
691
+ updated = updated.replace(originalSection, section);
692
+ changes.push(`Updated ${issueKey}`);
693
+ }
694
+ }
695
+
696
+ return { content: updated, changes };
697
+ }
698
+
699
+ function escapeRegex(str) {
700
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
701
+ }
702
+
703
+ // ─── Command Implementations ────────────────────────────────────────────────
704
+
705
+ function cmdCompileReport(flags) {
706
+ const tempDir = flags['temp-dir'];
707
+ const date = flags.date;
708
+
709
+ if (!tempDir || !date) {
710
+ console.error('Usage: compile-report --temp-dir <dir> --date <YYYY-MM-DD>');
711
+ process.exit(1);
712
+ }
713
+
714
+ if (!fs.existsSync(tempDir)) {
715
+ console.error(`Temp directory not found: ${tempDir}`);
716
+ process.exit(1);
717
+ }
718
+
719
+ const result = compileOverviewReport(tempDir, date);
720
+ if (result.error) {
721
+ console.error(result.error);
722
+ process.exit(1);
723
+ }
724
+
725
+ // Write report to temp dir as well for validation
726
+ fs.writeFileSync(path.join(tempDir, '_compiled-report.md'), result.report, 'utf8');
727
+
728
+ // Output the report to stdout
729
+ console.log(result.report);
730
+
731
+ // Write metadata to stderr for the agent to parse
732
+ console.error(JSON.stringify({
733
+ today: todayTag(),
734
+ issue_count: result.issue_count,
735
+ activity_count: result.activity_count,
736
+ report_file: path.join(tempDir, '_compiled-report.md'),
737
+ }));
738
+ }
739
+
740
+ function cmdUpdateTracker(flags) {
741
+ const trackerPath = flags.tracker;
742
+ const tempDir = flags['temp-dir'];
743
+ const date = flags.date;
744
+
745
+ if (!trackerPath || !tempDir || !date) {
746
+ console.error('Usage: update-tracker --tracker <path> --temp-dir <dir> --date <YYYY-MM-DD>');
747
+ process.exit(1);
748
+ }
749
+
750
+ if (!fs.existsSync(trackerPath)) {
751
+ console.error(`Tracker file not found: ${trackerPath}`);
752
+ process.exit(1);
753
+ }
754
+
755
+ const content = fs.readFileSync(trackerPath, 'utf8');
756
+ const { content: updated, changes } = applyTrackerUpdates(content, tempDir, date);
757
+
758
+ if (changes.length === 0) {
759
+ console.log(JSON.stringify({ today: todayTag(), updated: false, changes: [] }));
760
+ return;
761
+ }
762
+
763
+ fs.writeFileSync(trackerPath, updated, 'utf8');
764
+ console.log(JSON.stringify({ today: todayTag(), updated: true, changes }));
765
+ }
766
+
767
+ // ─── Async Helper ──────────────────────────────────────────────────────────
768
+
769
+ function execAsync(cmd) {
770
+ return new Promise((resolve, reject) => {
771
+ exec(cmd, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
772
+ if (err) reject(err);
773
+ else resolve(stdout);
774
+ });
775
+ });
776
+ }
777
+
778
+ /**
779
+ * Run promises in batches to avoid rate limits.
780
+ * @param {Array<() => Promise>} tasks - Array of functions returning promises
781
+ * @param {number} concurrency - Max concurrent tasks
782
+ * @returns {Promise<Array>} Results in same order as tasks
783
+ */
784
+ async function batchRun(tasks, concurrency = 15) {
785
+ const results = [];
786
+ for (let i = 0; i < tasks.length; i += concurrency) {
787
+ const batch = tasks.slice(i, i + concurrency);
788
+ const batchResults = await Promise.all(batch.map(fn => fn()));
789
+ results.push(...batchResults);
790
+ }
791
+ return results;
792
+ }
793
+
794
+ // ─── Command: startup ──────────────────────────────────────────────────────
795
+
796
+ async function cmdStartup(flags) {
797
+ const trackerPath = flags.tracker;
798
+ if (!trackerPath) {
799
+ console.error('Usage: startup --tracker <path>');
800
+ process.exit(1);
801
+ }
802
+
803
+ // Step 1: Check gh auth
804
+ let username = null;
805
+ try {
806
+ const authOutput = execSync('gh auth status 2>&1', { encoding: 'utf8' });
807
+ const userMatch = authOutput.match(/Logged in to github\.com.*account\s+(\S+)/i)
808
+ || authOutput.match(/Logged in to github\.com\s+as\s+(\S+)/i)
809
+ || authOutput.match(/account\s+(\S+)/i);
810
+ if (userMatch) username = userMatch[1];
811
+ } catch (err) {
812
+ console.log(JSON.stringify({
813
+ script_ok: true, auth: false, today: todayTag(),
814
+ error: 'Not authenticated with GitHub. Run `gh auth login` in your terminal to fix this.'
815
+ }));
816
+ return;
817
+ }
818
+
819
+ if (!username) {
820
+ // Try alternate extraction from gh api
821
+ try {
822
+ username = execSync('gh api user --jq .login', { encoding: 'utf8' }).trim();
823
+ } catch (_) {
824
+ console.log(JSON.stringify({
825
+ script_ok: true, auth: false, today: todayTag(),
826
+ error: 'Not authenticated with GitHub. Run `gh auth login` in your terminal to fix this.'
827
+ }));
828
+ return;
829
+ }
830
+ }
831
+
832
+ // Step 2: Parse tracker (or set empty defaults if no tracker)
833
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
834
+ .toISOString().split('T')[0];
835
+
836
+ let trackerExists = false;
837
+ let parsed = { username: null, active_issues: [], closed_issues: [], raw: '' };
838
+
839
+ if (fs.existsSync(trackerPath)) {
840
+ const content = fs.readFileSync(trackerPath, 'utf8');
841
+ if (content.trim()) {
842
+ trackerExists = true;
843
+ parsed = parseTrackerFile(content);
844
+ // Default null last_check_date to 30 days ago
845
+ for (const issue of parsed.active_issues) {
846
+ if (!issue.last_check_date) issue.last_check_date = thirtyDaysAgo;
847
+ }
848
+ }
849
+ }
850
+
851
+ const oldestCheckDate = parsed.active_issues
852
+ .map(i => i.last_check_date)
853
+ .filter(Boolean)
854
+ .sort()[0] || thirtyDaysAgo;
855
+
856
+ const allTrackedNumbers = [
857
+ ...parsed.active_issues.map(i => `${i.owner}/${i.repo}#${i.number}`),
858
+ ...parsed.closed_issues.map(i => `${i.owner}/${i.repo}#${i.number}`),
859
+ ];
860
+
861
+ // Step 3: Run two gh api search queries in parallel (always, even without tracker)
862
+ const searchDate = oldestCheckDate;
863
+ const openQuery = `search/issues?q=involves:${username}+is:open+updated:>${searchDate}&per_page=100`;
864
+ const closedQuery = `search/issues?q=involves:${username}+is:closed+closed:>${thirtyDaysAgo}&per_page=50`;
865
+
866
+ let openResults = [];
867
+ let closedResults = [];
868
+ let searchErrors = [];
869
+
870
+ // Run searches individually so one failure doesn't block the other
871
+ try {
872
+ const openRaw = await execAsync(`gh api "${openQuery}" --jq ".items"`);
873
+ openResults = JSON.parse(openRaw || '[]');
874
+ } catch (err) {
875
+ searchErrors.push(`open search failed: ${err.message}`);
876
+ openResults = [];
877
+ }
878
+
879
+ try {
880
+ const closedRaw = await execAsync(`gh api "${closedQuery}" --jq ".items"`);
881
+ closedResults = JSON.parse(closedRaw || '[]');
882
+ } catch (err) {
883
+ searchErrors.push(`closed search failed: ${err.message}`);
884
+ closedResults = [];
885
+ }
886
+
887
+ // Step 4: Identify new, reopened, recently closed
888
+ function issueKey(item) {
889
+ const urlParts = (item.repository_url || '').split('/');
890
+ const owner = urlParts[urlParts.length - 2];
891
+ const repo = urlParts[urlParts.length - 1];
892
+ return `${owner}/${repo}#${item.number}`;
893
+ }
894
+
895
+ function issueInfo(item) {
896
+ const urlParts = (item.repository_url || '').split('/');
897
+ return {
898
+ owner: urlParts[urlParts.length - 2],
899
+ repo: urlParts[urlParts.length - 1],
900
+ number: item.number,
901
+ title: item.title,
902
+ state: item.state,
903
+ updated_at: item.updated_at,
904
+ created_at: item.created_at,
905
+ html_url: item.html_url,
906
+ labels: (item.labels || []).map(l => l.name),
907
+ user: item.user ? item.user.login : null,
908
+ };
909
+ }
910
+
911
+ const trackedSet = new Set(allTrackedNumbers);
912
+ const closedNumbers = new Set(
913
+ parsed.closed_issues.map(i => `${i.owner}/${i.repo}#${i.number}`)
914
+ );
915
+
916
+ const newIssues = openResults
917
+ .filter(item => !trackedSet.has(issueKey(item)))
918
+ .map(issueInfo);
919
+
920
+ const reopenedIssues = openResults
921
+ .filter(item => closedNumbers.has(issueKey(item)))
922
+ .map(issueInfo);
923
+
924
+ const recentlyClosed = closedResults.map(issueInfo);
925
+
926
+ // Create temp directory for this session
927
+ const prefix = 'giu-checkin-';
928
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
929
+
930
+ console.log(JSON.stringify({
931
+ script_ok: true,
932
+ auth: true,
933
+ today: todayTag(),
934
+ username,
935
+ temp_dir: tempDir,
936
+ tracker_exists: trackerExists,
937
+ tracker_path: trackerPath,
938
+ config: parsed.config,
939
+ tracker_data: {
940
+ username: parsed.username,
941
+ active_issues: parsed.active_issues,
942
+ closed_issues: parsed.closed_issues,
943
+ all_tracked_numbers: allTrackedNumbers,
944
+ oldest_check_date: oldestCheckDate,
945
+ raw: parsed.raw,
946
+ },
947
+ new_issues: newIssues,
948
+ reopened_issues: reopenedIssues,
949
+ recently_closed: recentlyClosed,
950
+ search_errors: searchErrors,
951
+ }, null, 2));
952
+ }
953
+
954
+ // ─── Command: fetch-issues ─────────────────────────────────────────────────
955
+
956
+ async function cmdFetchIssues(flags) {
957
+ const tempDir = flags['temp-dir'];
958
+ const issuesFile = flags.issues;
959
+
960
+ if (!tempDir || !issuesFile) {
961
+ console.error('Usage: fetch-issues --temp-dir <dir> --issues <json-file>');
962
+ process.exit(1);
963
+ }
964
+
965
+ if (!fs.existsSync(issuesFile)) {
966
+ console.error(`Issues file not found: ${issuesFile}`);
967
+ process.exit(1);
968
+ }
969
+
970
+ const issues = JSON.parse(fs.readFileSync(issuesFile, 'utf8'));
971
+ const errors = [];
972
+ const files = [];
973
+
974
+ // Build all fetch tasks
975
+ const allTasks = [];
976
+ const taskMap = []; // Maps task index to { issueIdx, type }
977
+
978
+ // Fetch raw JSON from gh api (no --jq — process in Node to avoid Windows quoting issues)
979
+ function ghFetch(endpoint) {
980
+ return execAsync(`gh api ${endpoint}`)
981
+ .then(raw => JSON.parse(raw || '{}'))
982
+ .catch(e => ({ _error: e.message }));
983
+ }
984
+
985
+ for (let i = 0; i < issues.length; i++) {
986
+ const iss = issues[i];
987
+ const { owner, repo, number, last_check_date, known_dupes, upstream } = iss;
988
+ const issueEndpoint = `repos/${owner}/${repo}/issues/${number}`;
989
+
990
+ // Issue data (single call — extract metadata + body + author in Node)
991
+ allTasks.push(() => ghFetch(issueEndpoint));
992
+ taskMap.push({ issueIdx: i, type: 'issue' });
993
+
994
+ // Comments (all of them — filter by date in Node)
995
+ allTasks.push(() => execAsync(`gh api ${issueEndpoint}/comments --paginate`)
996
+ .then(raw => JSON.parse(raw || '[]'))
997
+ .catch(e => ({ _error: e.message })));
998
+ taskMap.push({ issueIdx: i, type: 'comments' });
999
+
1000
+ // Known dupe state checks
1001
+ const dupes = known_dupes || [];
1002
+ for (const dupe of dupes) {
1003
+ const dupeMatch = String(dupe).match(/(?:([^/]+)\/([^#]+))?#?(\d+)/);
1004
+ if (dupeMatch) {
1005
+ const dOwner = dupeMatch[1] || owner;
1006
+ const dRepo = dupeMatch[2] || repo;
1007
+ const dNumber = dupeMatch[3];
1008
+ allTasks.push(() => ghFetch(`repos/${dOwner}/${dRepo}/issues/${dNumber}`));
1009
+ taskMap.push({ issueIdx: i, type: 'dupe', dupeKey: `${dOwner}/${dRepo}#${dNumber}` });
1010
+ }
1011
+ }
1012
+
1013
+ // Upstream check
1014
+ if (upstream) {
1015
+ const uMatch = String(upstream).match(/([^/]+)\/([^#]+)#(\d+)/);
1016
+ if (uMatch) {
1017
+ allTasks.push(() => ghFetch(`repos/${uMatch[1]}/${uMatch[2]}/issues/${uMatch[3]}`));
1018
+ taskMap.push({ issueIdx: i, type: 'upstream' });
1019
+ }
1020
+ }
1021
+ }
1022
+
1023
+ // Run all tasks with batching (max 15 concurrent)
1024
+ const results = await batchRun(allTasks, 15);
1025
+
1026
+ // Organize results by issue — process raw API responses in Node
1027
+ const issueData = issues.map(() => ({
1028
+ metadata: null,
1029
+ body: null,
1030
+ comments: null,
1031
+ dupe_states: {},
1032
+ upstream_state: null,
1033
+ cross_references: [],
1034
+ urls: [],
1035
+ }));
1036
+
1037
+ for (let t = 0; t < results.length; t++) {
1038
+ const { issueIdx, type, dupeKey } = taskMap[t];
1039
+ const raw = results[t];
1040
+
1041
+ switch (type) {
1042
+ case 'issue': {
1043
+ // Extract metadata and body from the single issue response
1044
+ if (raw && !raw._error) {
1045
+ issueData[issueIdx].metadata = {
1046
+ state: raw.state,
1047
+ labels: (raw.labels || []).map(l => l.name),
1048
+ comments: raw.comments,
1049
+ updated: raw.updated_at,
1050
+ created: raw.created_at,
1051
+ html_url: raw.html_url,
1052
+ };
1053
+ issueData[issueIdx].body = {
1054
+ title: raw.title,
1055
+ body: raw.body,
1056
+ author: raw.user ? raw.user.login : null,
1057
+ };
1058
+ } else {
1059
+ issueData[issueIdx].metadata = raw;
1060
+ issueData[issueIdx].body = raw;
1061
+ }
1062
+ break;
1063
+ }
1064
+ case 'comments': {
1065
+ // Filter comments by last_check_date in Node
1066
+ const iss = issues[issueIdx];
1067
+ if (Array.isArray(raw)) {
1068
+ const filtered = iss.last_check_date
1069
+ ? raw.filter(c => c.created_at > iss.last_check_date)
1070
+ : raw;
1071
+ issueData[issueIdx].comments = filtered.map(c => ({
1072
+ author: c.user ? c.user.login : null,
1073
+ date: c.created_at ? c.created_at.split('T')[0] : null,
1074
+ body: c.body,
1075
+ }));
1076
+ } else {
1077
+ issueData[issueIdx].comments = raw;
1078
+ }
1079
+ break;
1080
+ }
1081
+ case 'dupe': {
1082
+ if (raw && !raw._error) {
1083
+ issueData[issueIdx].dupe_states[dupeKey] = {
1084
+ state: raw.state,
1085
+ updated: raw.updated_at,
1086
+ };
1087
+ } else {
1088
+ issueData[issueIdx].dupe_states[dupeKey] = raw;
1089
+ }
1090
+ break;
1091
+ }
1092
+ case 'upstream': {
1093
+ if (raw && !raw._error) {
1094
+ issueData[issueIdx].upstream_state = {
1095
+ state: raw.state,
1096
+ labels: (raw.labels || []).map(l => l.name),
1097
+ updated: raw.updated_at,
1098
+ };
1099
+ } else {
1100
+ issueData[issueIdx].upstream_state = raw;
1101
+ }
1102
+ break;
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ // Post-process: extract cross_references and urls from body + comments
1108
+ for (let i = 0; i < issues.length; i++) {
1109
+ const data = issueData[i];
1110
+ const allText = collectText(data);
1111
+
1112
+ // Extract #NUMBER and owner/repo#NUMBER patterns
1113
+ const crossRefs = new Set();
1114
+ const refRe = /(?:([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+))?#(\d+)/g;
1115
+ let m;
1116
+ while ((m = refRe.exec(allText)) !== null) {
1117
+ crossRefs.add(m[1] ? `${m[1]}#${m[2]}` : `#${m[2]}`);
1118
+ }
1119
+ data.cross_references = [...crossRefs];
1120
+
1121
+ // Extract URLs
1122
+ const urlSet = new Set();
1123
+ const urlRe = /https?:\/\/[^\s)>\]"']+/g;
1124
+ while ((m = urlRe.exec(allText)) !== null) {
1125
+ urlSet.add(m[0].replace(/[.,;:]+$/, ''));
1126
+ }
1127
+ data.urls = [...urlSet];
1128
+
1129
+ // Write raw JSON per issue
1130
+ const iss = issues[i];
1131
+ const outFile = path.join(tempDir, `raw-${iss.owner}-${iss.repo}-${iss.number}.json`);
1132
+ try {
1133
+ fs.writeFileSync(outFile, JSON.stringify(data, null, 2), 'utf8');
1134
+ files.push(outFile);
1135
+ } catch (err) {
1136
+ errors.push(`Failed to write ${outFile}: ${err.message}`);
1137
+ }
1138
+ }
1139
+
1140
+ console.log(JSON.stringify({ today: todayTag(), fetched: files.length, files, errors }));
1141
+ }
1142
+
1143
+ /**
1144
+ * Collect all text content from issue data for cross-reference/URL extraction.
1145
+ */
1146
+ function collectText(data) {
1147
+ const parts = [];
1148
+ if (data.body) {
1149
+ if (typeof data.body === 'string') parts.push(data.body);
1150
+ else if (data.body.body) parts.push(data.body.body);
1151
+ }
1152
+ if (data.comments) {
1153
+ if (typeof data.comments === 'string') parts.push(data.comments);
1154
+ else if (Array.isArray(data.comments)) {
1155
+ for (const c of data.comments) {
1156
+ if (typeof c === 'string') parts.push(c);
1157
+ else if (c && c.body) parts.push(c.body);
1158
+ }
1159
+ }
1160
+ }
1161
+ return parts.join('\n');
1162
+ }
1163
+
1164
+ // ─── Command: build-tracker ────────────────────────────────────────────────
1165
+
1166
+ function cmdBuildTracker(flags) {
1167
+ const tempDir = flags['temp-dir'];
1168
+ const templatePath = flags.template;
1169
+ const username = flags.username;
1170
+ const trackerPath = flags.tracker;
1171
+ const closedJsonPath = flags['closed-json'];
1172
+ const date = flags.date || localDate();
1173
+
1174
+ if (!tempDir || !templatePath || !username || !trackerPath) {
1175
+ console.error('Usage: build-tracker --temp-dir <dir> --template <path> --username <name> --tracker <path> [--closed-json <path>]');
1176
+ process.exit(1);
1177
+ }
1178
+
1179
+ // Step 1: Read template
1180
+ if (!fs.existsSync(templatePath)) {
1181
+ console.error(`Template not found: ${templatePath}`);
1182
+ process.exit(1);
1183
+ }
1184
+ let tracker = fs.readFileSync(templatePath, 'utf8');
1185
+
1186
+ // Step 2: Replace USERNAME_HERE
1187
+ tracker = tracker.replace(/USERNAME_HERE/g, username);
1188
+
1189
+ // Step 3: Read all issue-*.md result files from temp dir
1190
+ if (!fs.existsSync(tempDir)) {
1191
+ console.error(`Temp directory not found: ${tempDir}`);
1192
+ process.exit(1);
1193
+ }
1194
+
1195
+ const resultFiles = fs.readdirSync(tempDir).filter(f =>
1196
+ f.startsWith('issue-') && f.endsWith('.md')
1197
+ );
1198
+
1199
+ const entries = [];
1200
+ for (const file of resultFiles) {
1201
+ const content = fs.readFileSync(path.join(tempDir, file), 'utf8');
1202
+ const { meta, body } = parseFrontmatter(content);
1203
+ if (meta.type !== 'issue') continue;
1204
+
1205
+ // Build tracker entry from result file
1206
+ const entry = buildTrackerEntry(meta, body, date);
1207
+ entries.push(entry);
1208
+ }
1209
+
1210
+ // Step 4: Insert active entries into tracker
1211
+ const activeEntriesText = entries.join('\n');
1212
+ tracker = tracker.replace(
1213
+ /(## Active Issues[^\n]*\n)([\s\S]*?)(## Closed)/,
1214
+ `$1\n${activeEntriesText}\n$3`
1215
+ );
1216
+
1217
+ // Step 5: Handle closed issues if provided
1218
+ let closedCount = 0;
1219
+ if (closedJsonPath && fs.existsSync(closedJsonPath)) {
1220
+ const closedIssues = JSON.parse(fs.readFileSync(closedJsonPath, 'utf8'));
1221
+ closedCount = closedIssues.length;
1222
+ let closedText = '';
1223
+ for (const ci of closedIssues) {
1224
+ closedText += `### ${ci.owner}/${ci.repo}#${ci.number} — ${ci.title}\n`;
1225
+ closedText += `- ${ci.resolution || 'Closed.'}\n\n`;
1226
+ }
1227
+ if (closedText) {
1228
+ tracker = tracker.replace(
1229
+ /(## Closed[^\n]*\n)/,
1230
+ `$1\n${closedText}`
1231
+ );
1232
+ }
1233
+ }
1234
+
1235
+ // Step 6: Write tracker
1236
+ fs.writeFileSync(trackerPath, tracker, 'utf8');
1237
+
1238
+ console.log(JSON.stringify({
1239
+ today: todayTag(),
1240
+ written: true,
1241
+ path: trackerPath,
1242
+ active_count: entries.length,
1243
+ closed_count: closedCount,
1244
+ }));
1245
+ }
1246
+
1247
+ /**
1248
+ * Build a tracker entry string from result file frontmatter and body.
1249
+ */
1250
+ function buildTrackerEntry(meta, body, date) {
1251
+ const lines = [];
1252
+ lines.push(`### ${meta.owner}/${meta.repo}#${meta.number} — ${meta.title}`);
1253
+
1254
+ // Goal — from tracker updates or frontmatter
1255
+ const trackerUpdatesForGoal = extractSection(body, 'Tracker Updates') || '';
1256
+ const goalLine = trackerUpdatesForGoal.match(/^goal:\s*(.+)/m);
1257
+ if (goalLine) {
1258
+ lines.push(`- **Goal:** ${goalLine[1].trim()}`);
1259
+ }
1260
+
1261
+ // Role
1262
+ lines.push(`- **Role:** ${meta.role || 'Unknown'}`);
1263
+
1264
+ // Filed
1265
+ if (meta.filed) {
1266
+ lines.push(`- **Filed:** ${meta.filed}`);
1267
+ }
1268
+
1269
+ // Status — from ## Status Summary + labels
1270
+ const statusSummary = extractSection(body, 'Status Summary') || 'Open.';
1271
+ const labels = meta.labels
1272
+ ? (Array.isArray(meta.labels) ? meta.labels.join(', ') : meta.labels)
1273
+ : '';
1274
+ const trackerUpdates = extractSection(body, 'Tracker Updates') || '';
1275
+ const statusLine = trackerUpdates.match(/^status_summary:\s*(.+)/m);
1276
+ const statusText = statusLine
1277
+ ? statusLine[1].trim()
1278
+ : `${meta.state || 'Open'}. Labels: ${labels}. ${statusSummary.split('\n')[0]}`;
1279
+ const dateStr = date || localDate();
1280
+ lines.push(`- **Status as of ${dateStr}:** ${statusText}`);
1281
+
1282
+ // What to check — from ## Watch For or tracker updates
1283
+ const watchLine = trackerUpdates.match(/^what_to_check:\s*(.+)/m);
1284
+ const watchFor = watchLine
1285
+ ? watchLine[1].trim()
1286
+ : (extractSection(body, 'Watch For') || 'Monitor for updates.').split('\n')[0].replace(/^-\s*/, '');
1287
+ lines.push(`- **What to check:** ${watchFor}`);
1288
+
1289
+ // Related — from ## Cross-References
1290
+ const crossRefs = extractSection(body, 'Cross-References');
1291
+ if (crossRefs && crossRefs.trim() !== 'None' && crossRefs.trim() !== 'None found.') {
1292
+ lines.push(`- **Related:** ${crossRefs.split('\n')[0].trim()}`);
1293
+ }
1294
+
1295
+ // Upstream
1296
+ const upstream = extractSection(body, 'Upstream');
1297
+ if (upstream && upstream.trim() !== 'N/A' && upstream.trim() !== 'None') {
1298
+ lines.push(`- **Upstream:** ${upstream.split('\n')[0].trim()}`);
1299
+ }
1300
+
1301
+ // Key Context -> workaround / future
1302
+ const keyContext = extractSection(body, 'Key Context');
1303
+ if (keyContext && keyContext.trim() !== 'N/A') {
1304
+ // Check for workaround
1305
+ const workaroundMatch = keyContext.match(/[Ww]orkaround:?\s*(.+)/);
1306
+ if (workaroundMatch) {
1307
+ lines.push(`- **Workaround:** ${workaroundMatch[1].trim()}`);
1308
+ }
1309
+ }
1310
+
1311
+ // Duplicates from ## Duplicates and Related
1312
+ const dupeSection = extractSection(body, 'Duplicates and Related')
1313
+ || extractSection(body, 'Known');
1314
+ if (dupeSection) {
1315
+ const dupeLines = dupeSection.split('\n').filter(l => l.match(/^\s*-?\s*[#*]/));
1316
+ if (dupeLines.length > 0) {
1317
+ lines.push(`- **Duplicates found (${dateStr}):**`);
1318
+ for (const dl of dupeLines) {
1319
+ const cleaned = dl.replace(/^[\s-]*/, ' - ');
1320
+ lines.push(cleaned);
1321
+ }
1322
+ }
1323
+ }
1324
+
1325
+ // Next steps
1326
+ const nextSteps = extractSection(body, 'Next Steps');
1327
+ if (nextSteps && nextSteps.trim() !== 'None' && nextSteps.trim() !== 'None — no action needed.') {
1328
+ lines.push(`- **Next steps (now):** ${nextSteps.split('\n')[0].replace(/^-\s*\[.\]\s*/, '').trim()}`);
1329
+ } else {
1330
+ lines.push(`- **Next steps (now):** None — no action needed.`);
1331
+ }
1332
+
1333
+ // Future — from Key Context
1334
+ if (keyContext && keyContext.trim() !== 'N/A') {
1335
+ lines.push(`- **Future:** ${keyContext.split('\n')[0].replace(/^-\s*/, '').trim()}`);
1336
+ }
1337
+
1338
+ // History — from history_entry lines in Tracker Updates
1339
+ const historyEntries = [];
1340
+ if (trackerUpdates) {
1341
+ const historyRe = /^history_entry:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*(.+)/gm;
1342
+ let hm;
1343
+ while ((hm = historyRe.exec(trackerUpdates)) !== null) {
1344
+ historyEntries.push(` - **${hm[1]}:** ${hm[2].trim()}`);
1345
+ }
1346
+ }
1347
+ if (historyEntries.length === 0) {
1348
+ // Default entry on initial build
1349
+ historyEntries.push(` - **${dateStr}:** Added to tracker`);
1350
+ }
1351
+ lines.push(`- **History:**`);
1352
+ for (const he of historyEntries) {
1353
+ lines.push(he);
1354
+ }
1355
+
1356
+ lines.push('');
1357
+ return lines.join('\n');
1358
+ }