harnery 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +29 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +4 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +94 -33
  7. package/dist/commands/browse-ai.js +1 -1
  8. package/dist/commands/browse.d.ts.map +1 -1
  9. package/dist/commands/browse.js +41 -9
  10. package/dist/commands/cookies.js +1 -1
  11. package/dist/commands/decision.d.ts +4 -0
  12. package/dist/commands/decision.d.ts.map +1 -0
  13. package/dist/commands/decision.js +354 -0
  14. package/dist/commands/deinit.d.ts.map +1 -1
  15. package/dist/commands/deinit.js +4 -0
  16. package/dist/commands/devtools.d.ts +4 -0
  17. package/dist/commands/devtools.d.ts.map +1 -0
  18. package/dist/commands/devtools.js +239 -0
  19. package/dist/commands/docs.d.ts.map +1 -1
  20. package/dist/commands/docs.js +74 -2
  21. package/dist/commands/doctor.js +12 -4
  22. package/dist/commands/env.d.ts.map +1 -1
  23. package/dist/commands/env.js +3 -63
  24. package/dist/commands/fetch.js +1 -1
  25. package/dist/commands/init.d.ts +1 -0
  26. package/dist/commands/init.d.ts.map +1 -1
  27. package/dist/commands/init.js +54 -14
  28. package/dist/commands/scratch.js +1 -1
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +273 -62
  31. package/dist/commands/web-fetch.js +1 -1
  32. package/dist/core/agents/coord-client.d.ts.map +1 -1
  33. package/dist/core/agents/coord-client.js +32 -8
  34. package/dist/core/agents/events/consume.d.ts +25 -2
  35. package/dist/core/agents/events/consume.d.ts.map +1 -1
  36. package/dist/core/agents/events/consume.js +55 -7
  37. package/dist/core/agents/events/emit.d.ts +2 -1
  38. package/dist/core/agents/events/emit.d.ts.map +1 -1
  39. package/dist/core/agents/events/emit.js +6 -1
  40. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  41. package/dist/core/agents/rules/claim-conflict.js +16 -5
  42. package/dist/core/agents/state/scratch.d.ts +1 -1
  43. package/dist/core/agents/state/scratch.js +2 -2
  44. package/dist/core/config.d.ts +10 -0
  45. package/dist/core/config.d.ts.map +1 -1
  46. package/dist/core/config.js +13 -0
  47. package/dist/core/hooks/cli.js +3 -3
  48. package/dist/core/hooks/effects/index.d.ts +11 -7
  49. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  50. package/dist/core/hooks/effects/index.js +15 -17
  51. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  52. package/dist/core/hooks/events/emit.js +4 -0
  53. package/dist/core/hooks/events/rotate.d.ts +43 -0
  54. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  55. package/dist/core/hooks/events/rotate.js +142 -0
  56. package/dist/core/hooks/harness/events.d.ts +11 -1
  57. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  58. package/dist/core/hooks/harness/events.js +22 -3
  59. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  60. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  61. package/dist/core/hooks/harness/wiring.js +34 -5
  62. package/dist/core/scratch/index.d.ts.map +1 -0
  63. package/dist/{lib → core}/scratch/index.js +2 -2
  64. package/dist/lib/agent-browser/client.js +1 -1
  65. package/dist/lib/browser/client.d.ts +14 -0
  66. package/dist/lib/browser/client.d.ts.map +1 -1
  67. package/dist/lib/browser/client.js +20 -0
  68. package/dist/lib/browser/index.d.ts +1 -0
  69. package/dist/lib/browser/index.d.ts.map +1 -1
  70. package/dist/lib/browser/runts.d.ts +44 -0
  71. package/dist/lib/browser/runts.d.ts.map +1 -0
  72. package/dist/lib/browser/runts.js +193 -0
  73. package/dist/lib/completion/walk.js +1 -1
  74. package/dist/lib/cookies/client.d.ts +1 -1
  75. package/dist/lib/cookies/client.d.ts.map +1 -1
  76. package/dist/lib/cookies/client.js +1 -1
  77. package/dist/lib/decision/index.d.ts +212 -0
  78. package/dist/lib/decision/index.d.ts.map +1 -0
  79. package/dist/lib/decision/index.js +523 -0
  80. package/dist/lib/devtools.d.ts +178 -0
  81. package/dist/lib/devtools.d.ts.map +1 -0
  82. package/dist/lib/devtools.js +1328 -0
  83. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  84. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  85. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  86. package/dist/lib/docs-frontmatter.d.ts +33 -0
  87. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  88. package/dist/lib/docs-frontmatter.js +130 -0
  89. package/dist/lib/docs-index.d.ts +1 -0
  90. package/dist/lib/docs-index.d.ts.map +1 -1
  91. package/dist/lib/docs-index.js +4 -5
  92. package/dist/lib/docs-lint.d.ts +3 -0
  93. package/dist/lib/docs-lint.d.ts.map +1 -1
  94. package/dist/lib/docs-lint.js +67 -12
  95. package/dist/lib/docs-meta.d.ts +14 -0
  96. package/dist/lib/docs-meta.d.ts.map +1 -0
  97. package/dist/lib/docs-meta.js +34 -0
  98. package/dist/lib/docs-sweep.d.ts +12 -0
  99. package/dist/lib/docs-sweep.d.ts.map +1 -1
  100. package/dist/lib/docs-sweep.js +98 -103
  101. package/dist/lib/format.js +2 -2
  102. package/dist/lib/http/index.d.ts +1 -0
  103. package/dist/lib/http/index.d.ts.map +1 -1
  104. package/dist/lib/http/index.js +1 -0
  105. package/dist/lib/http/request.d.ts +77 -0
  106. package/dist/lib/http/request.d.ts.map +1 -0
  107. package/dist/lib/http/request.js +105 -0
  108. package/dist/lib/instructions/apply.d.ts +63 -0
  109. package/dist/lib/instructions/apply.d.ts.map +1 -0
  110. package/dist/lib/instructions/apply.js +255 -0
  111. package/dist/lib/instructions/splice.d.ts +73 -0
  112. package/dist/lib/instructions/splice.d.ts.map +1 -0
  113. package/dist/lib/instructions/splice.js +118 -0
  114. package/dist/lib/instructions/templates.d.ts +45 -0
  115. package/dist/lib/instructions/templates.d.ts.map +1 -0
  116. package/dist/lib/instructions/templates.js +258 -0
  117. package/dist/lib/tunnel/gate.d.ts +1 -0
  118. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  119. package/dist/lib/tunnel/gate.js +15 -10
  120. package/dist/lib/tunnel/state.d.ts +11 -1
  121. package/dist/lib/tunnel/state.d.ts.map +1 -1
  122. package/dist/lib/tunnel/state.js +8 -3
  123. package/package.json +9 -6
  124. package/src/commander.ts +35 -0
  125. package/src/commands/agents.ts +97 -29
  126. package/src/commands/browse-ai.ts +1 -1
  127. package/src/commands/browse.ts +63 -8
  128. package/src/commands/cookies.ts +1 -1
  129. package/src/commands/decision.ts +438 -0
  130. package/src/commands/deinit.ts +5 -0
  131. package/src/commands/devtools.ts +284 -0
  132. package/src/commands/docs.ts +86 -2
  133. package/src/commands/doctor.ts +13 -4
  134. package/src/commands/env.ts +11 -77
  135. package/src/commands/fetch.ts +1 -1
  136. package/src/commands/init.ts +66 -15
  137. package/src/commands/scratch.ts +1 -1
  138. package/src/commands/tunnel.ts +316 -65
  139. package/src/commands/web-fetch.ts +1 -1
  140. package/src/core/agents/coord-client.ts +34 -7
  141. package/src/core/agents/events/consume.ts +65 -7
  142. package/src/core/agents/events/emit.ts +7 -1
  143. package/src/core/agents/rules/claim-conflict.ts +17 -6
  144. package/src/core/agents/state/scratch.ts +2 -2
  145. package/src/core/config.ts +15 -1
  146. package/src/core/hooks/cli.ts +3 -3
  147. package/src/core/hooks/effects/index.ts +23 -16
  148. package/src/core/hooks/events/emit.ts +5 -0
  149. package/src/core/hooks/events/rotate.ts +151 -0
  150. package/src/core/hooks/harness/events.ts +30 -3
  151. package/src/core/hooks/harness/wiring.ts +46 -5
  152. package/src/{lib → core}/scratch/index.ts +2 -2
  153. package/src/lib/agent-browser/client.ts +1 -1
  154. package/src/lib/browser/client.ts +28 -0
  155. package/src/lib/browser/index.ts +4 -0
  156. package/src/lib/browser/runts.ts +218 -0
  157. package/src/lib/completion/walk.ts +1 -1
  158. package/src/lib/cookies/client.ts +2 -2
  159. package/src/lib/decision/index.ts +685 -0
  160. package/src/lib/devtools.ts +1653 -0
  161. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  162. package/src/lib/docs-frontmatter.ts +151 -0
  163. package/src/lib/docs-index.ts +4 -5
  164. package/src/lib/docs-lint.ts +61 -11
  165. package/src/lib/docs-meta.ts +44 -0
  166. package/src/lib/docs-sweep.ts +104 -102
  167. package/src/lib/format.ts +2 -2
  168. package/src/lib/http/index.ts +1 -0
  169. package/src/lib/http/request.ts +154 -0
  170. package/src/lib/instructions/apply.ts +318 -0
  171. package/src/lib/instructions/splice.ts +148 -0
  172. package/src/lib/instructions/templates.ts +295 -0
  173. package/src/lib/tunnel/gate.ts +15 -10
  174. package/src/lib/tunnel/state.ts +19 -4
  175. package/dist/lib/scratch/index.d.ts.map +0 -1
  176. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -1,5 +1,6 @@
1
1
  import { existsSync as __existsSyncForDocs } from "node:fs";
2
2
  import { resolve as __resolveForDocs } from "node:path";
3
+ import { hasYamlStatus } from "./docs-frontmatter.ts";
3
4
  import { sh } from "./exec.ts";
4
5
 
5
6
  // Module-level docs context, initialized by initDocsContext() before any
@@ -9,15 +10,18 @@ import { sh } from "./exec.ts";
9
10
  let REPO_ROOT = "";
10
11
  let SUBMODULES: readonly string[] = [];
11
12
  let EXTRA_EXCLUDED_PREFIXES: readonly string[] = [];
13
+ let DOCS_ROOT_ALLOWLIST: readonly string[] = [];
12
14
 
13
15
  export function initDocsContext(opts: {
14
16
  repoRoot: string;
15
17
  submodules: readonly string[];
16
18
  extraExcludedPrefixes?: readonly string[];
19
+ docsRootAllowlist?: readonly string[];
17
20
  }): void {
18
21
  REPO_ROOT = opts.repoRoot;
19
22
  SUBMODULES = opts.submodules;
20
23
  EXTRA_EXCLUDED_PREFIXES = opts.extraExcludedPrefixes ?? [];
24
+ DOCS_ROOT_ALLOWLIST = opts.docsRootAllowlist ?? [];
21
25
  }
22
26
 
23
27
  function submodulePath(name: string): string {
@@ -171,10 +175,13 @@ function isDeclaredMonolith(path: string): boolean {
171
175
  return /INTENTIONAL-MONOLITH/i.test(head);
172
176
  }
173
177
 
174
- /** Detect whether a file carries a Status line in its opening block */
175
- function hasStatusHeader(path: string): boolean {
176
- const head = readHead(path, 15);
177
- return /\*\*Status:\*\*/i.test(head);
178
+ /** Detect whether a file carries lifecycle status in leading YAML frontmatter. */
179
+ export function hasStatusHeader(path: string): boolean {
180
+ try {
181
+ return hasYamlStatus(readFileSync(path, "utf8"));
182
+ } catch {
183
+ return false;
184
+ }
178
185
  }
179
186
 
180
187
  // --- Individual checks ---
@@ -239,6 +246,46 @@ function checkRootAllowlist(repoName: string, repoPath: string): Violation[] {
239
246
  return violations;
240
247
  }
241
248
 
249
+ /**
250
+ * The host project's `docs/` root is an entry tier: only allowlisted files may
251
+ * sit loose there; topic docs belong in `docs/<topic>/` subdirs. Config-gated —
252
+ * a no-op unless the host supplies `docsRootAllowlist`. Parent-repo only:
253
+ * submodule `docs/` roots have their own entry tiers, not this one.
254
+ */
255
+ function checkDocsRootAllowlist(repoName: string, repoPath: string): Violation[] {
256
+ const violations: Violation[] = [];
257
+ if (DOCS_ROOT_ALLOWLIST.length === 0) return violations; // opt-in
258
+ if (repoName !== "(root)") return violations; // parent repo only
259
+ const allow = new Set(DOCS_ROOT_ALLOWLIST);
260
+ const docsDir = join(repoPath, "docs");
261
+ let entries: string[];
262
+ try {
263
+ entries = readdirSync(docsDir);
264
+ } catch {
265
+ return violations; // no docs/ dir — nothing to check
266
+ }
267
+ for (const entry of entries) {
268
+ // Subdirs are the intended home for topic docs; only loose files matter.
269
+ let isFile: boolean;
270
+ try {
271
+ isFile = statSync(join(docsDir, entry)).isFile();
272
+ } catch {
273
+ continue;
274
+ }
275
+ if (!isFile) continue;
276
+ if (!(entry.endsWith(".md") || entry.endsWith(".json"))) continue;
277
+ if (allow.has(entry)) continue;
278
+ violations.push({
279
+ severity: "error",
280
+ repo: repoName,
281
+ path: join("docs", entry),
282
+ rule: "docs-root-file",
283
+ message: `${entry} is not allowed loose at docs/ root — move it into a docs/<topic>/ subdir (or add it to context.docsRootAllowlist if it's a genuine entry-tier doc)`,
284
+ });
285
+ }
286
+ return violations;
287
+ }
288
+
242
289
  /** No SCREAMING_SNAKE_CASE filenames anywhere */
243
290
  function checkNamingConvention(repoName: string, _repoPath: string, files: string[]): Violation[] {
244
291
  const violations: Violation[] = [];
@@ -330,26 +377,28 @@ function checkChangelogNames(repoName: string, _repoPath: string, files: string[
330
377
  return violations;
331
378
  }
332
379
 
333
- /** Plans and issues must carry a Status header (content check, slow) */
380
+ /** Plans, issues, and handoffs must carry YAML lifecycle status (content check, slow). */
334
381
  function checkStatusHeaders(repoName: string, repoPath: string, files: string[]): Violation[] {
335
382
  const violations: Violation[] = [];
336
- const targetDirs = ["docs/plans/", "docs/issues/"];
383
+ const targetDirs = ["docs/plans/", "docs/issues/", "docs/handoffs/"];
337
384
  for (const rel of files) {
338
385
  const dirMatch = targetDirs.some((d) => rel.startsWith(d));
339
386
  if (!dirMatch) continue;
340
387
  const name = basename(rel);
341
388
  if (name === "README.md") continue;
342
- // Skip archive subdir
343
- if (rel.includes("/archive/")) continue;
344
389
  const full = join(repoPath, rel);
345
390
  if (!hasStatusHeader(full)) {
346
- const kind = rel.startsWith("docs/plans/") ? "plan" : "issue";
391
+ const kind = rel.startsWith("docs/plans/")
392
+ ? "plan"
393
+ : rel.startsWith("docs/issues/")
394
+ ? "issue"
395
+ : "handoff";
347
396
  violations.push({
348
- severity: "warning",
397
+ severity: "error",
349
398
  repo: repoName,
350
399
  path: join(repoName === "(root)" ? "" : repoName, rel),
351
400
  rule: "missing-status-header",
352
- message: `${kind} missing **Status:** line in opening block`,
401
+ message: `${kind} missing status in leading YAML frontmatter`,
353
402
  });
354
403
  }
355
404
  }
@@ -402,6 +451,7 @@ export async function runLint(opts: LintOpts): Promise<Violation[]> {
402
451
  for (const { name, path, isSubmodule } of repos) {
403
452
  violations.push(...checkEntryTier(name, path, isSubmodule));
404
453
  violations.push(...checkRootAllowlist(name, path));
454
+ violations.push(...checkDocsRootAllowlist(name, path));
405
455
 
406
456
  const files = await findMarkdownFiles(path);
407
457
  violations.push(...checkNamingConvention(name, path, files));
@@ -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";