harnery 0.6.0 → 0.7.1

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 (137) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +19 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +2 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +51 -4
  7. package/dist/commands/deinit.d.ts.map +1 -1
  8. package/dist/commands/deinit.js +4 -0
  9. package/dist/commands/devtools.d.ts +4 -0
  10. package/dist/commands/devtools.d.ts.map +1 -0
  11. package/dist/commands/devtools.js +239 -0
  12. package/dist/commands/docs.d.ts.map +1 -1
  13. package/dist/commands/docs.js +69 -1
  14. package/dist/commands/doctor.js +12 -4
  15. package/dist/commands/env.d.ts.map +1 -1
  16. package/dist/commands/env.js +3 -63
  17. package/dist/commands/init.d.ts +1 -0
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +54 -14
  20. package/dist/commands/scratch.js +1 -1
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +273 -62
  23. package/dist/commands/web-fetch.js +1 -1
  24. package/dist/core/agents/cli.js +48 -0
  25. package/dist/core/agents/coord-client.d.ts.map +1 -1
  26. package/dist/core/agents/coord-client.js +32 -8
  27. package/dist/core/agents/events/emit.d.ts.map +1 -1
  28. package/dist/core/agents/events/emit.js +4 -0
  29. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  30. package/dist/core/agents/rules/claim-conflict.js +16 -5
  31. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  32. package/dist/core/agents/state/heartbeat-projector.js +10 -3
  33. package/dist/core/config.d.ts +10 -0
  34. package/dist/core/config.d.ts.map +1 -1
  35. package/dist/core/config.js +13 -0
  36. package/dist/core/hooks/cli.js +3 -3
  37. package/dist/core/hooks/effects/index.d.ts +11 -7
  38. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  39. package/dist/core/hooks/effects/index.js +15 -18
  40. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  41. package/dist/core/hooks/events/emit.js +4 -0
  42. package/dist/core/hooks/events/rotate.d.ts +43 -0
  43. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  44. package/dist/core/hooks/events/rotate.js +142 -0
  45. package/dist/core/hooks/harness/events.d.ts +11 -1
  46. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  47. package/dist/core/hooks/harness/events.js +22 -3
  48. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  49. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  50. package/dist/core/hooks/harness/wiring.js +34 -5
  51. package/dist/core/scratch/index.d.ts.map +1 -0
  52. package/dist/{lib → core}/scratch/index.js +2 -2
  53. package/dist/lib/devtools.d.ts +178 -0
  54. package/dist/lib/devtools.d.ts.map +1 -0
  55. package/dist/lib/devtools.js +1328 -0
  56. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  57. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  58. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  59. package/dist/lib/docs-frontmatter.d.ts +33 -0
  60. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  61. package/dist/lib/docs-frontmatter.js +130 -0
  62. package/dist/lib/docs-index.d.ts +1 -0
  63. package/dist/lib/docs-index.d.ts.map +1 -1
  64. package/dist/lib/docs-index.js +4 -5
  65. package/dist/lib/docs-lint.d.ts +2 -0
  66. package/dist/lib/docs-lint.d.ts.map +1 -1
  67. package/dist/lib/docs-lint.js +18 -12
  68. package/dist/lib/docs-meta.d.ts +14 -0
  69. package/dist/lib/docs-meta.d.ts.map +1 -0
  70. package/dist/lib/docs-meta.js +34 -0
  71. package/dist/lib/docs-sweep.d.ts +12 -0
  72. package/dist/lib/docs-sweep.d.ts.map +1 -1
  73. package/dist/lib/docs-sweep.js +98 -103
  74. package/dist/lib/format.js +2 -2
  75. package/dist/lib/http/index.d.ts +1 -0
  76. package/dist/lib/http/index.d.ts.map +1 -1
  77. package/dist/lib/http/index.js +1 -0
  78. package/dist/lib/http/request.d.ts +77 -0
  79. package/dist/lib/http/request.d.ts.map +1 -0
  80. package/dist/lib/http/request.js +105 -0
  81. package/dist/lib/instructions/apply.d.ts +63 -0
  82. package/dist/lib/instructions/apply.d.ts.map +1 -0
  83. package/dist/lib/instructions/apply.js +255 -0
  84. package/dist/lib/instructions/splice.d.ts +73 -0
  85. package/dist/lib/instructions/splice.d.ts.map +1 -0
  86. package/dist/lib/instructions/splice.js +118 -0
  87. package/dist/lib/instructions/templates.d.ts +45 -0
  88. package/dist/lib/instructions/templates.d.ts.map +1 -0
  89. package/dist/lib/instructions/templates.js +258 -0
  90. package/dist/lib/tunnel/gate.d.ts +1 -0
  91. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  92. package/dist/lib/tunnel/gate.js +14 -9
  93. package/dist/lib/tunnel/state.d.ts +11 -1
  94. package/dist/lib/tunnel/state.d.ts.map +1 -1
  95. package/dist/lib/tunnel/state.js +8 -3
  96. package/package.json +7 -6
  97. package/src/commander.ts +23 -0
  98. package/src/commands/agents.ts +50 -3
  99. package/src/commands/deinit.ts +5 -0
  100. package/src/commands/devtools.ts +284 -0
  101. package/src/commands/docs.ts +81 -1
  102. package/src/commands/doctor.ts +13 -4
  103. package/src/commands/env.ts +11 -77
  104. package/src/commands/init.ts +66 -15
  105. package/src/commands/scratch.ts +1 -1
  106. package/src/commands/tunnel.ts +316 -65
  107. package/src/commands/web-fetch.ts +1 -1
  108. package/src/core/agents/cli.ts +55 -0
  109. package/src/core/agents/coord-client.ts +34 -7
  110. package/src/core/agents/events/emit.ts +5 -0
  111. package/src/core/agents/rules/claim-conflict.ts +17 -6
  112. package/src/core/agents/state/heartbeat-projector.ts +11 -3
  113. package/src/core/config.ts +14 -0
  114. package/src/core/hooks/cli.ts +3 -3
  115. package/src/core/hooks/effects/index.ts +23 -17
  116. package/src/core/hooks/events/emit.ts +5 -0
  117. package/src/core/hooks/events/rotate.ts +151 -0
  118. package/src/core/hooks/harness/events.ts +30 -3
  119. package/src/core/hooks/harness/wiring.ts +46 -5
  120. package/src/{lib → core}/scratch/index.ts +2 -2
  121. package/src/lib/devtools.ts +1653 -0
  122. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  123. package/src/lib/docs-frontmatter.ts +151 -0
  124. package/src/lib/docs-index.ts +4 -5
  125. package/src/lib/docs-lint.ts +17 -11
  126. package/src/lib/docs-meta.ts +44 -0
  127. package/src/lib/docs-sweep.ts +104 -102
  128. package/src/lib/format.ts +2 -2
  129. package/src/lib/http/index.ts +1 -0
  130. package/src/lib/http/request.ts +154 -0
  131. package/src/lib/instructions/apply.ts +318 -0
  132. package/src/lib/instructions/splice.ts +148 -0
  133. package/src/lib/instructions/templates.ts +295 -0
  134. package/src/lib/tunnel/gate.ts +14 -9
  135. package/src/lib/tunnel/state.ts +19 -4
  136. package/dist/lib/scratch/index.d.ts.map +0 -1
  137. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -0,0 +1,44 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import { parseFrontmatter } from "./docs-frontmatter.ts";
4
+
5
+ export interface DocsMetadata {
6
+ path: string;
7
+ data: Record<string, unknown>;
8
+ }
9
+
10
+ /**
11
+ * Read the leading YAML frontmatter from a markdown file.
12
+ *
13
+ * Relative paths resolve from the host repo root so the command behaves the
14
+ * same no matter which directory invoked it.
15
+ */
16
+ export function readDocsMetadata(repoRoot: string, inputPath: string): DocsMetadata {
17
+ const path = isAbsolute(inputPath) ? resolve(inputPath) : resolve(repoRoot, inputPath);
18
+ try {
19
+ if (!statSync(path).isFile()) throw new Error("not a file");
20
+ } catch {
21
+ throw new Error(`Documentation file not found: ${inputPath}`);
22
+ }
23
+
24
+ const parsed = parseFrontmatter(readFileSync(path, "utf8"));
25
+ if (parsed.raw === null) {
26
+ throw new Error(`Documentation file has no leading YAML frontmatter: ${inputPath}`);
27
+ }
28
+ if (Object.keys(parsed.data).length === 0) {
29
+ throw new Error(`Documentation frontmatter is empty or malformed: ${inputPath}`);
30
+ }
31
+ return { path, data: parsed.data };
32
+ }
33
+
34
+ /** Read one top-level metadata key, failing when the key is absent. */
35
+ export function readDocsMetadataKey(
36
+ metadata: Record<string, unknown>,
37
+ key: string,
38
+ inputPath: string,
39
+ ): unknown {
40
+ if (!Object.hasOwn(metadata, key)) {
41
+ throw new Error(`Documentation frontmatter key '${key}' not found: ${inputPath}`);
42
+ }
43
+ return metadata[key];
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync as __existsSyncForDocs } from "node:fs";
2
2
  import { resolve as __resolveForDocs } from "node:path";
3
+ import { readDocStatus } from "./docs-frontmatter.ts";
3
4
  import { sh } from "./exec.ts";
4
5
 
5
6
  // Module-level docs context, initialized by initDocsContext() before any
@@ -21,7 +22,7 @@ function isSubmoduleInitialized(name: string): boolean {
21
22
  return __existsSyncForDocs(__resolveForDocs(REPO_ROOT, name, ".git"));
22
23
  }
23
24
 
24
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
25
+ import { existsSync, readdirSync, statSync } from "node:fs";
25
26
  import { join, relative } from "node:path";
26
27
 
27
28
  /**
@@ -34,6 +35,10 @@ import { join, relative } from "node:path";
34
35
  *
35
36
  * Audit files and issue files under `docs/audits/` are explicitly **not**
36
37
  * flagged for age; they're immutable records by design.
38
+ *
39
+ * Performance: ages come from one `git log --name-only` per repo (not one
40
+ * `git log` per file). A naive per-file spawn was ~1min+ on large hosts
41
+ * (thousands of topic docs) and looked hung when piped.
37
42
  */
38
43
 
39
44
  export interface SweepOpts {
@@ -65,12 +70,48 @@ const RUNBOOK_DAYS = 180;
65
70
  const TOPIC_DOC_DAYS = 365;
66
71
  const DECISIONS_DORMANT_DAYS = 180;
67
72
 
68
- /** Days since last git commit touching a file */
69
- async function lastCommitAgeDays(cwd: string, file: string): Promise<number | null> {
70
- const result = await sh(`git log -1 --format=%aI -- "${file}"`, { cwd });
71
- if (result.exitCode !== 0 || !result.stdout.trim()) return null;
72
- const ms = Date.now() - new Date(result.stdout.trim()).getTime();
73
- return Math.floor(ms / (1000 * 60 * 60 * 24));
73
+ type AgeMap = Map<string, number>;
74
+
75
+ /**
76
+ * Parse a `git log --format="COMMIT %aI" --name-only` body into
77
+ * repo-relative path → age in days. Newest commit wins.
78
+ * Exported for unit tests.
79
+ */
80
+ export function parseDocsAgeLog(stdout: string, nowMs: number = Date.now()): AgeMap {
81
+ const ages: AgeMap = new Map();
82
+ let currentAge: number | null = null;
83
+ for (const line of stdout.split("\n")) {
84
+ if (line.startsWith("COMMIT ")) {
85
+ const iso = line.slice("COMMIT ".length).trim();
86
+ const ms = Date.parse(iso);
87
+ currentAge = Number.isNaN(ms) ? null : Math.floor((nowMs - ms) / (1000 * 60 * 60 * 24));
88
+ continue;
89
+ }
90
+ if (!line || currentAge == null) continue;
91
+ if (!line.endsWith(".md")) continue;
92
+ // First (newest) sighting wins
93
+ if (!ages.has(line)) ages.set(line, currentAge);
94
+ }
95
+ return ages;
96
+ }
97
+
98
+ /**
99
+ * One `git log --name-only` for the whole docs/ tree. Docs histories are
100
+ * small even without --since (a large host's full docs log is ~5k lines / <100ms),
101
+ * so we take the full history for accurate ages on old files.
102
+ */
103
+ async function loadDocsAges(cwd: string): Promise<AgeMap> {
104
+ const result = await sh(`git log --format="COMMIT %aI" --name-only -- docs/`, {
105
+ cwd,
106
+ timeout: 120_000,
107
+ });
108
+ if (result.exitCode !== 0 || !result.stdout.trim()) return new Map();
109
+ return parseDocsAgeLog(result.stdout);
110
+ }
111
+
112
+ /** Age for a tracked path, or null if untracked / never under docs/. */
113
+ function ageDays(ages: AgeMap, rel: string): number | null {
114
+ return ages.has(rel) ? ages.get(rel)! : null;
74
115
  }
75
116
 
76
117
  /** Days since ANY commit in the repo (measures repo activity) */
@@ -81,47 +122,35 @@ async function lastRepoCommitAgeDays(cwd: string): Promise<number | null> {
81
122
  return Math.floor(ms / (1000 * 60 * 60 * 24));
82
123
  }
83
124
 
84
- function readStatus(filePath: string): string | null {
85
- try {
86
- const content = readFileSync(filePath, "utf8");
87
- const head = content.split("\n").slice(0, 20).join("\n");
88
- const m = head.match(/\*\*Status:\*\*\s*([a-zA-Z][a-zA-Z-]*)/);
89
- return m ? m[1]!.toLowerCase() : null;
90
- } catch {
91
- return null;
125
+ function walkMdFiles(dir: string, skipReadme = false): string[] {
126
+ const out: string[] = [];
127
+ for (const entry of readdirSync(dir)) {
128
+ const full = join(dir, entry);
129
+ let st: ReturnType<typeof statSync> | undefined;
130
+ try {
131
+ st = statSync(full);
132
+ } catch {
133
+ continue;
134
+ }
135
+ if (st.isDirectory()) {
136
+ out.push(...walkMdFiles(full, skipReadme));
137
+ } else if (entry.endsWith(".md") && !(skipReadme && entry === "README.md")) {
138
+ out.push(full);
139
+ }
92
140
  }
141
+ return out;
93
142
  }
94
143
 
95
- async function sweepPlans(repoName: string, repoPath: string, items: SweepItem[]): Promise<void> {
144
+ function sweepPlans(repoName: string, repoPath: string, ages: AgeMap, items: SweepItem[]): void {
96
145
  const plansDir = join(repoPath, "docs", "plans");
97
146
  if (!existsSync(plansDir)) return;
98
147
 
99
- const walk = (dir: string): string[] => {
100
- const out: string[] = [];
101
- for (const entry of readdirSync(dir)) {
102
- const full = join(dir, entry);
103
- let st: ReturnType<typeof statSync> | undefined;
104
- try {
105
- st = statSync(full);
106
- } catch {
107
- continue;
108
- }
109
- if (st.isDirectory()) {
110
- out.push(...walk(full));
111
- } else if (entry.endsWith(".md") && entry !== "README.md") {
112
- out.push(full);
113
- }
114
- }
115
- return out;
116
- };
117
-
118
- const files = walk(plansDir);
119
- for (const full of files) {
148
+ for (const full of walkMdFiles(plansDir, true)) {
120
149
  const rel = relative(repoPath, full);
121
150
  const displayPath = join(repoName === "(root)" ? "" : repoName, rel);
122
151
  const isArchived = rel.includes("/archive/");
123
- const status = readStatus(full);
124
- const age = await lastCommitAgeDays(repoPath, rel);
152
+ const status = readDocStatus(full, "plan");
153
+ const age = ageDays(ages, rel);
125
154
  if (age == null) continue;
126
155
 
127
156
  if (!isArchived && status === "in-progress" && age > STALLED_PLAN_DAYS) {
@@ -145,7 +174,7 @@ async function sweepPlans(repoName: string, repoPath: string, items: SweepItem[]
145
174
  }
146
175
  }
147
176
 
148
- async function sweepIssues(repoName: string, repoPath: string, items: SweepItem[]): Promise<void> {
177
+ function sweepIssues(repoName: string, repoPath: string, ages: AgeMap, items: SweepItem[]): void {
149
178
  const issuesDir = join(repoPath, "docs", "issues");
150
179
  if (!existsSync(issuesDir)) return;
151
180
 
@@ -154,9 +183,9 @@ async function sweepIssues(repoName: string, repoPath: string, items: SweepItem[
154
183
  const full = join(issuesDir, entry);
155
184
  const rel = join("docs", "issues", entry);
156
185
  const displayPath = join(repoName === "(root)" ? "" : repoName, rel);
157
- const status = readStatus(full);
186
+ const status = readDocStatus(full, "issue");
158
187
  if (status !== "open") continue;
159
- const age = await lastCommitAgeDays(repoPath, rel);
188
+ const age = ageDays(ages, rel);
160
189
  if (age == null || age <= OPEN_ISSUE_DAYS) continue;
161
190
  items.push({
162
191
  kind: "open-issue-cold",
@@ -168,38 +197,15 @@ async function sweepIssues(repoName: string, repoPath: string, items: SweepItem[
168
197
  }
169
198
  }
170
199
 
171
- async function sweepHandoffs(
172
- repoName: string,
173
- repoPath: string,
174
- items: SweepItem[],
175
- ): Promise<void> {
200
+ function sweepHandoffs(repoName: string, repoPath: string, ages: AgeMap, items: SweepItem[]): void {
176
201
  const handoffsDir = join(repoPath, "docs", "handoffs");
177
202
  if (!existsSync(handoffsDir)) return;
178
203
 
179
- const walk = (dir: string): string[] => {
180
- const out: string[] = [];
181
- for (const entry of readdirSync(dir)) {
182
- const full = join(dir, entry);
183
- let st: ReturnType<typeof statSync> | undefined;
184
- try {
185
- st = statSync(full);
186
- } catch {
187
- continue;
188
- }
189
- if (st.isDirectory()) {
190
- out.push(...walk(full));
191
- } else if (entry.endsWith(".md")) {
192
- out.push(full);
193
- }
194
- }
195
- return out;
196
- };
197
-
198
- for (const full of walk(handoffsDir)) {
204
+ for (const full of walkMdFiles(handoffsDir)) {
199
205
  const rel = relative(repoPath, full);
200
- const status = readStatus(full);
206
+ const status = readDocStatus(full, "handoff");
201
207
  if (status !== "open") continue;
202
- const age = await lastCommitAgeDays(repoPath, rel);
208
+ const age = ageDays(ages, rel);
203
209
  if (age == null || age <= COLD_HANDOFF_DAYS) continue;
204
210
  const displayPath = join(repoName === "(root)" ? "" : repoName, rel);
205
211
  items.push({
@@ -212,10 +218,10 @@ async function sweepHandoffs(
212
218
  }
213
219
  }
214
220
 
215
- async function sweepRunbook(repoName: string, repoPath: string, items: SweepItem[]): Promise<void> {
221
+ function sweepRunbook(repoName: string, repoPath: string, ages: AgeMap, items: SweepItem[]): void {
216
222
  const runbook = join(repoPath, "docs", "runbook.md");
217
223
  if (!existsSync(runbook)) return;
218
- const age = await lastCommitAgeDays(repoPath, "docs/runbook.md");
224
+ const age = ageDays(ages, "docs/runbook.md");
219
225
  if (age == null || age <= RUNBOOK_DAYS) return;
220
226
  const displayPath = join(repoName === "(root)" ? "" : repoName, "docs/runbook.md");
221
227
  items.push({
@@ -227,21 +233,27 @@ async function sweepRunbook(repoName: string, repoPath: string, items: SweepItem
227
233
  });
228
234
  }
229
235
 
230
- async function sweepTopicDocs(
236
+ function sweepTopicDocs(
231
237
  repoName: string,
232
238
  repoPath: string,
239
+ ages: AgeMap,
233
240
  items: SweepItem[],
234
- ): Promise<void> {
241
+ ): void {
235
242
  const docsDir = join(repoPath, "docs");
236
243
  if (!existsSync(docsDir)) return;
237
244
 
238
- // Skip known date-stamped or lifecycle-managed dirs
245
+ // Skip known date-stamped or lifecycle-managed dirs (and vendor dumps).
246
+ // handoffs/inquiries are lifecycle-managed elsewhere; vendors match the
247
+ // docs freshness scanner's IGNORE_DIRS.
239
248
  const skipDirs = new Set([
240
249
  "audits",
241
250
  "issues",
242
251
  "plans",
243
252
  "changelogs",
244
253
  "emails", // parent-specific
254
+ "handoffs",
255
+ "inquiries",
256
+ "vendors",
245
257
  ]);
246
258
 
247
259
  for (const entry of readdirSync(docsDir)) {
@@ -255,26 +267,9 @@ async function sweepTopicDocs(
255
267
  }
256
268
  if (!st.isDirectory()) continue;
257
269
 
258
- // For each topic dir, find .md files and check age
259
- const walk = (dir: string): string[] => {
260
- const out: string[] = [];
261
- for (const f of readdirSync(dir)) {
262
- const fp = join(dir, f);
263
- let s: ReturnType<typeof statSync> | undefined;
264
- try {
265
- s = statSync(fp);
266
- } catch {
267
- continue;
268
- }
269
- if (s.isDirectory()) out.push(...walk(fp));
270
- else if (f.endsWith(".md")) out.push(fp);
271
- }
272
- return out;
273
- };
274
-
275
- for (const full2 of walk(full)) {
270
+ for (const full2 of walkMdFiles(full)) {
276
271
  const rel = relative(repoPath, full2);
277
- const age = await lastCommitAgeDays(repoPath, rel);
272
+ const age = ageDays(ages, rel);
278
273
  if (age == null || age <= TOPIC_DOC_DAYS) continue;
279
274
  const displayPath = join(repoName === "(root)" ? "" : repoName, rel);
280
275
  items.push({
@@ -291,11 +286,12 @@ async function sweepTopicDocs(
291
286
  async function sweepDecisions(
292
287
  repoName: string,
293
288
  repoPath: string,
289
+ ages: AgeMap,
294
290
  items: SweepItem[],
295
291
  ): Promise<void> {
296
292
  const decisions = join(repoPath, "docs", "decisions.md");
297
293
  if (!existsSync(decisions)) return;
298
- const age = await lastCommitAgeDays(repoPath, "docs/decisions.md");
294
+ const age = ageDays(ages, "docs/decisions.md");
299
295
  const repoAge = await lastRepoCommitAgeDays(repoPath);
300
296
  if (age == null || repoAge == null) return;
301
297
  // Only flag if the repo is active (recent commits) but decisions haven't moved
@@ -322,13 +318,18 @@ export async function runSweep(opts: SweepOpts): Promise<SweepItem[]> {
322
318
  const filtered = filter ? targets.filter((t) => t.name === filter) : targets;
323
319
 
324
320
  const items: SweepItem[] = [];
325
- for (const { name, path } of filtered) {
326
- await sweepPlans(name, path, items);
327
- await sweepIssues(name, path, items);
328
- await sweepHandoffs(name, path, items);
329
- await sweepRunbook(name, path, items);
330
- await sweepTopicDocs(name, path, items);
331
- await sweepDecisions(name, path, items);
321
+ // Load ages per repo in parallel — one git log each, not one per file.
322
+ const ageMaps = await Promise.all(filtered.map((t) => loadDocsAges(t.path)));
323
+
324
+ for (let i = 0; i < filtered.length; i++) {
325
+ const { name, path } = filtered[i]!;
326
+ const ages = ageMaps[i]!;
327
+ sweepPlans(name, path, ages, items);
328
+ sweepIssues(name, path, ages, items);
329
+ sweepHandoffs(name, path, ages, items);
330
+ sweepRunbook(name, path, ages, items);
331
+ sweepTopicDocs(name, path, ages, items);
332
+ await sweepDecisions(name, path, ages, items);
332
333
  }
333
334
 
334
335
  // Sort by severity proxy: oldest first within kind
@@ -343,6 +344,7 @@ export async function runSweep(opts: SweepOpts): Promise<SweepItem[]> {
343
344
  */
344
345
  export async function countColdHandoffs(): Promise<number> {
345
346
  const items: SweepItem[] = [];
346
- await sweepHandoffs("(root)", REPO_ROOT, items);
347
+ const ages = await loadDocsAges(REPO_ROOT);
348
+ sweepHandoffs("(root)", REPO_ROOT, ages, items);
347
349
  return items.length;
348
350
  }
package/src/lib/format.ts CHANGED
@@ -48,7 +48,7 @@ export function colorJson(value: unknown, indent = 0): string {
48
48
  // Class instances that define toJSON() (Big.js, Decimal.js, Date, etc.)
49
49
  // would render as their raw internal shape if we walked Object.keys
50
50
  // directly. Unwrap once so callers see the intended representation
51
- // (Big.js → "1.97" instead of {s,e,c} from BigQuery NUMERIC fields).
51
+ // (e.g. Big.js → "1.97" instead of its internal {s,e,c} fields).
52
52
  const maybeJsonable = v as { toJSON?: () => unknown };
53
53
  if (typeof maybeJsonable.toJSON === "function") {
54
54
  return fmt(maybeJsonable.toJSON(), depth);
@@ -115,7 +115,7 @@ function stringify(val: unknown): string {
115
115
  if (val === null || val === undefined) return "NULL";
116
116
  if (typeof val === "object") {
117
117
  if (val instanceof Date) return val.toISOString();
118
- // BigQuery returns { value: "..." } for some types
118
+ // Some data sources wrap a scalar as { value: "..." }; unwrap to the inner value.
119
119
  if ("value" in val && Object.keys(val).length === 1) {
120
120
  return String((val as { value: unknown }).value);
121
121
  }
@@ -1 +1,2 @@
1
1
  export { type FetchOptions, type FetchResult, fetchWithJar } from "./client.js";
2
+ export * from "./request.ts";
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Retrying JSON-API request — the toolkit-tier HTTP primitive for host CLIs'
3
+ * vendor clients.
4
+ *
5
+ * Extracted from the first embedding host, where ten vendor clients carried
6
+ * byte-similar copies of the same loop: abort-controller timeout, retry on
7
+ * 429/5xx with exponential backoff + jitter, `Retry-After` honored when sane,
8
+ * vendor-specific error taxonomy applied by the caller. This module owns the
9
+ * loop; callers keep their auth headers and error classes:
10
+ *
11
+ * const r = await requestWithRetries(url, {
12
+ * method, body,
13
+ * headers: { Authorization: `ApiKey ${key}`, Accept: "application/json" },
14
+ * timeoutMs: this.timeoutMs,
15
+ * maxRetries: this.maxRetries,
16
+ * onResponse: ({ status }) => log(`${method} ${url} → ${status}`),
17
+ * networkError: (msg) => new VendorError("network_error", msg),
18
+ * });
19
+ * if (!r.ok) throw makeHttpError(r.status, url, r.text, r.headers);
20
+ *
21
+ * Design choices, so they survive review:
22
+ * - Terminal non-2xx responses RETURN (`ok: false`) rather than throw — the
23
+ * vendor error taxonomy belongs to the caller, not this module.
24
+ * - Only terminal NETWORK failures throw (after retries), because there is
25
+ * no response to hand back; `networkError` lets the caller keep its class.
26
+ * - The response body is always read (even on retried statuses) so keep-alive
27
+ * sockets are released.
28
+ */
29
+
30
+ export interface RetryingResponse {
31
+ /** `status` in the 2xx range. */
32
+ ok: boolean;
33
+ status: number;
34
+ /** Response body as text; callers handle JSON parsing. */
35
+ text: string;
36
+ url: string;
37
+ headers: Headers;
38
+ }
39
+
40
+ export interface RequestWithRetriesOptions {
41
+ /** HTTP method. Default GET. */
42
+ method?: string;
43
+ /** Extra headers. Content-Type defaults to application/json when a non-string body is given. */
44
+ headers?: Record<string, string>;
45
+ /**
46
+ * Request body. Strings, Uint8Array, FormData, Blob, and ReadableStream pass
47
+ * through untouched; any other value is JSON.stringify'd (with a
48
+ * Content-Type: application/json default).
49
+ */
50
+ body?: unknown;
51
+ /** Per-attempt timeout (AbortController). Default 30s. */
52
+ timeoutMs?: number;
53
+ /** Retries after the first attempt. Default 3. */
54
+ maxRetries?: number;
55
+ /** Which statuses to retry. Default: 429 and 5xx. */
56
+ shouldRetry?: (status: number) => boolean;
57
+ /** Delay before retry `attempt` (0-based). Default `backoffDelayMs`. */
58
+ delayMs?: (attempt: number, retryAfterSeconds?: number | null) => number;
59
+ /** Observability hook — fires once per received response (every attempt). */
60
+ onResponse?: (info: { method: string; url: string; status: number; attempt: number }) => void;
61
+ /**
62
+ * Wrap a terminal network failure (fetch threw on the last attempt) in the
63
+ * caller's error class. Default: a plain Error with the message.
64
+ */
65
+ networkError?: (message: string, url: string, retries: number) => Error;
66
+ }
67
+
68
+ /**
69
+ * Exponential backoff with jitter: 500ms · 2^attempt, capped at 30s, plus up
70
+ * to 250ms of jitter. A sane `Retry-After` (0 < s < 60) short-circuits the
71
+ * curve — the server knows better than the guess.
72
+ */
73
+ export function backoffDelayMs(attempt: number, retryAfterSeconds?: number | null): number {
74
+ if (retryAfterSeconds && retryAfterSeconds > 0 && retryAfterSeconds < 60) {
75
+ return retryAfterSeconds * 1000 + Math.floor(Math.random() * 250);
76
+ }
77
+ const base = Math.min(30_000, 500 * 2 ** attempt);
78
+ return base + Math.floor(Math.random() * 250);
79
+ }
80
+
81
+ function sleep(ms: number): Promise<void> {
82
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
83
+ }
84
+
85
+ export async function requestWithRetries(
86
+ url: string,
87
+ opts: RequestWithRetriesOptions = {},
88
+ ): Promise<RetryingResponse> {
89
+ const method = opts.method ?? "GET";
90
+ const timeoutMs = opts.timeoutMs ?? 30_000;
91
+ const maxRetries = opts.maxRetries ?? 3;
92
+ const shouldRetry = opts.shouldRetry ?? ((status: number) => status === 429 || status >= 500);
93
+ const delayMs = opts.delayMs ?? backoffDelayMs;
94
+
95
+ let attempt = 0;
96
+ for (;;) {
97
+ const controller = new AbortController();
98
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
99
+
100
+ const headers: Record<string, string> = { ...(opts.headers ?? {}) };
101
+ const init: RequestInit = { method, headers, signal: controller.signal };
102
+ if (opts.body !== undefined && opts.body !== null) {
103
+ const passthrough =
104
+ typeof opts.body === "string" ||
105
+ opts.body instanceof Uint8Array ||
106
+ opts.body instanceof FormData ||
107
+ opts.body instanceof Blob ||
108
+ opts.body instanceof ReadableStream;
109
+ if (passthrough) {
110
+ init.body = opts.body as BodyInit;
111
+ } else {
112
+ if (!Object.keys(headers).some((k) => k.toLowerCase() === "content-type")) {
113
+ headers["Content-Type"] = "application/json";
114
+ }
115
+ init.body = JSON.stringify(opts.body);
116
+ }
117
+ }
118
+
119
+ let res: Response;
120
+ try {
121
+ res = await fetch(url, init);
122
+ } catch (err: unknown) {
123
+ clearTimeout(timer);
124
+ if (attempt < maxRetries) {
125
+ await sleep(delayMs(attempt));
126
+ attempt++;
127
+ continue;
128
+ }
129
+ const msg = err instanceof Error ? err.message : String(err);
130
+ const message = `request failed after ${maxRetries} retries: ${msg} (URL: ${url})`;
131
+ throw opts.networkError ? opts.networkError(msg, url, maxRetries) : new Error(message);
132
+ }
133
+ clearTimeout(timer);
134
+
135
+ opts.onResponse?.({ method, url, status: res.status, attempt });
136
+
137
+ if (shouldRetry(res.status) && attempt < maxRetries) {
138
+ const retryAfter = Number(res.headers.get("retry-after"));
139
+ // Drain the body so the socket is reusable before we sleep.
140
+ await res.text().catch(() => undefined);
141
+ await sleep(delayMs(attempt, Number.isFinite(retryAfter) ? retryAfter : null));
142
+ attempt++;
143
+ continue;
144
+ }
145
+
146
+ return {
147
+ ok: res.ok,
148
+ status: res.status,
149
+ text: await res.text(),
150
+ url,
151
+ headers: res.headers,
152
+ };
153
+ }
154
+ }