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
@@ -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.js";
3
4
  import { sh } from "./exec.js";
4
5
  // Module-level docs context, initialized by initDocsContext() before any
5
6
  // other function in this file is called. Consumers pass repo metadata
@@ -130,10 +131,14 @@ function isDeclaredMonolith(path) {
130
131
  const head = readHead(path, 10);
131
132
  return /INTENTIONAL-MONOLITH/i.test(head);
132
133
  }
133
- /** Detect whether a file carries a Status line in its opening block */
134
- function hasStatusHeader(path) {
135
- const head = readHead(path, 15);
136
- return /\*\*Status:\*\*/i.test(head);
134
+ /** Detect whether a file carries lifecycle status in leading YAML frontmatter. */
135
+ export function hasStatusHeader(path) {
136
+ try {
137
+ return hasYamlStatus(readFileSync(path, "utf8"));
138
+ }
139
+ catch {
140
+ return false;
141
+ }
137
142
  }
138
143
  // --- Individual checks ---
139
144
  /** Entry tier files exist at the repo root */
@@ -343,10 +348,10 @@ function checkChangelogNames(repoName, _repoPath, files) {
343
348
  }
344
349
  return violations;
345
350
  }
346
- /** Plans and issues must carry a Status header (content check, slow) */
351
+ /** Plans, issues, and handoffs must carry YAML lifecycle status (content check, slow). */
347
352
  function checkStatusHeaders(repoName, repoPath, files) {
348
353
  const violations = [];
349
- const targetDirs = ["docs/plans/", "docs/issues/"];
354
+ const targetDirs = ["docs/plans/", "docs/issues/", "docs/handoffs/"];
350
355
  for (const rel of files) {
351
356
  const dirMatch = targetDirs.some((d) => rel.startsWith(d));
352
357
  if (!dirMatch)
@@ -354,18 +359,19 @@ function checkStatusHeaders(repoName, repoPath, files) {
354
359
  const name = basename(rel);
355
360
  if (name === "README.md")
356
361
  continue;
357
- // Skip archive subdir
358
- if (rel.includes("/archive/"))
359
- continue;
360
362
  const full = join(repoPath, rel);
361
363
  if (!hasStatusHeader(full)) {
362
- const kind = rel.startsWith("docs/plans/") ? "plan" : "issue";
364
+ const kind = rel.startsWith("docs/plans/")
365
+ ? "plan"
366
+ : rel.startsWith("docs/issues/")
367
+ ? "issue"
368
+ : "handoff";
363
369
  violations.push({
364
- severity: "warning",
370
+ severity: "error",
365
371
  repo: repoName,
366
372
  path: join(repoName === "(root)" ? "" : repoName, rel),
367
373
  rule: "missing-status-header",
368
- message: `${kind} missing **Status:** line in opening block`,
374
+ message: `${kind} missing status in leading YAML frontmatter`,
369
375
  });
370
376
  }
371
377
  }
@@ -0,0 +1,14 @@
1
+ export interface DocsMetadata {
2
+ path: string;
3
+ data: Record<string, unknown>;
4
+ }
5
+ /**
6
+ * Read the leading YAML frontmatter from a markdown file.
7
+ *
8
+ * Relative paths resolve from the host repo root so the command behaves the
9
+ * same no matter which directory invoked it.
10
+ */
11
+ export declare function readDocsMetadata(repoRoot: string, inputPath: string): DocsMetadata;
12
+ /** Read one top-level metadata key, failing when the key is absent. */
13
+ export declare function readDocsMetadataKey(metadata: Record<string, unknown>, key: string, inputPath: string): unknown;
14
+ //# sourceMappingURL=docs-meta.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"docs-meta.d.ts","sourceRoot":"","sources":["../../src/lib/docs-meta.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,YAAY,CAgBlF;AAED,uEAAuE;AACvE,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,GAChB,OAAO,CAKT"}
@@ -0,0 +1,34 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import { parseFrontmatter } from "./docs-frontmatter.js";
4
+ /**
5
+ * Read the leading YAML frontmatter from a markdown file.
6
+ *
7
+ * Relative paths resolve from the host repo root so the command behaves the
8
+ * same no matter which directory invoked it.
9
+ */
10
+ export function readDocsMetadata(repoRoot, inputPath) {
11
+ const path = isAbsolute(inputPath) ? resolve(inputPath) : resolve(repoRoot, inputPath);
12
+ try {
13
+ if (!statSync(path).isFile())
14
+ throw new Error("not a file");
15
+ }
16
+ catch {
17
+ throw new Error(`Documentation file not found: ${inputPath}`);
18
+ }
19
+ const parsed = parseFrontmatter(readFileSync(path, "utf8"));
20
+ if (parsed.raw === null) {
21
+ throw new Error(`Documentation file has no leading YAML frontmatter: ${inputPath}`);
22
+ }
23
+ if (Object.keys(parsed.data).length === 0) {
24
+ throw new Error(`Documentation frontmatter is empty or malformed: ${inputPath}`);
25
+ }
26
+ return { path, data: parsed.data };
27
+ }
28
+ /** Read one top-level metadata key, failing when the key is absent. */
29
+ export function readDocsMetadataKey(metadata, key, inputPath) {
30
+ if (!Object.hasOwn(metadata, key)) {
31
+ throw new Error(`Documentation frontmatter key '${key}' not found: ${inputPath}`);
32
+ }
33
+ return metadata[key];
34
+ }
@@ -12,6 +12,10 @@ export declare function initDocsContext(opts: {
12
12
  *
13
13
  * Audit files and issue files under `docs/audits/` are explicitly **not**
14
14
  * flagged for age; they're immutable records by design.
15
+ *
16
+ * Performance: ages come from one `git log --name-only` per repo (not one
17
+ * `git log` per file). A naive per-file spawn was ~1min+ on large hosts
18
+ * (thousands of topic docs) and looked hung when piped.
15
19
  */
16
20
  export interface SweepOpts {
17
21
  repo?: string;
@@ -24,6 +28,13 @@ export interface SweepItem {
24
28
  message: string;
25
29
  }
26
30
  export type SweepKind = "stalled-plan" | "unarchived-shipped" | "open-issue-cold" | "cold-handoff" | "runbook-unverified" | "topic-doc-stale" | "decisions-dormant";
31
+ type AgeMap = Map<string, number>;
32
+ /**
33
+ * Parse a `git log --format="COMMIT %aI" --name-only` body into
34
+ * repo-relative path → age in days. Newest commit wins.
35
+ * Exported for unit tests.
36
+ */
37
+ export declare function parseDocsAgeLog(stdout: string, nowMs?: number): AgeMap;
27
38
  export declare function runSweep(opts: SweepOpts): Promise<SweepItem[]>;
28
39
  /**
29
40
  * Cheap parent-repo-only count of `cold-handoff` items, used by `docs lint`
@@ -31,4 +42,5 @@ export declare function runSweep(opts: SweepOpts): Promise<SweepItem[]>;
31
42
  * submodule (handoffs live under the parent's docs/handoffs/ by convention).
32
43
  */
33
44
  export declare function countColdHandoffs(): Promise<number>;
45
+ export {};
34
46
  //# sourceMappingURL=docs-sweep.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"docs-sweep.d.ts","sourceRoot":"","sources":["../../src/lib/docs-sweep.ts"],"names":[],"mappings":"AAUA,wBAAgB,eAAe,CAAC,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAG/F;AAaD;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,SAAS;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,SAAS,GACjB,cAAc,GACd,oBAAoB,GACpB,iBAAiB,GACjB,cAAc,GACd,oBAAoB,GACpB,iBAAiB,GACjB,mBAAmB,CAAC;AAgQxB,wBAAsB,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAuBpE;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,MAAM,CAAC,CAIzD"}
1
+ {"version":3,"file":"docs-sweep.d.ts","sourceRoot":"","sources":["../../src/lib/docs-sweep.ts"],"names":[],"mappings":"AAWA,wBAAgB,eAAe,CAAC,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAG/F;AAaD;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,SAAS;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,SAAS,GACjB,cAAc,GACd,oBAAoB,GACpB,iBAAiB,GACjB,cAAc,GACd,oBAAoB,GACpB,iBAAiB,GACjB,mBAAmB,CAAC;AAUxB,KAAK,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAElC;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,GAAE,MAAmB,GAAG,MAAM,CAgBlF;AAsND,wBAAsB,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CA4BpE;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,MAAM,CAAC,CAKzD"}
@@ -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.js";
3
4
  import { sh } from "./exec.js";
4
5
  // Module-level docs context, initialized by initDocsContext() before any
5
6
  // other function in this file is called. The repo root + submodule list
@@ -16,7 +17,7 @@ function submodulePath(name) {
16
17
  function isSubmoduleInitialized(name) {
17
18
  return __existsSyncForDocs(__resolveForDocs(REPO_ROOT, name, ".git"));
18
19
  }
19
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
20
+ import { existsSync, readdirSync, statSync } from "node:fs";
20
21
  import { join, relative } from "node:path";
21
22
  const STALLED_PLAN_DAYS = 90;
22
23
  const UNARCHIVED_SHIPPED_DAYS = 180;
@@ -25,13 +26,48 @@ const COLD_HANDOFF_DAYS = 30;
25
26
  const RUNBOOK_DAYS = 180;
26
27
  const TOPIC_DOC_DAYS = 365;
27
28
  const DECISIONS_DORMANT_DAYS = 180;
28
- /** Days since last git commit touching a file */
29
- async function lastCommitAgeDays(cwd, file) {
30
- const result = await sh(`git log -1 --format=%aI -- "${file}"`, { cwd });
29
+ /**
30
+ * Parse a `git log --format="COMMIT %aI" --name-only` body into
31
+ * repo-relative path → age in days. Newest commit wins.
32
+ * Exported for unit tests.
33
+ */
34
+ export function parseDocsAgeLog(stdout, nowMs = Date.now()) {
35
+ const ages = new Map();
36
+ let currentAge = null;
37
+ for (const line of stdout.split("\n")) {
38
+ if (line.startsWith("COMMIT ")) {
39
+ const iso = line.slice("COMMIT ".length).trim();
40
+ const ms = Date.parse(iso);
41
+ currentAge = Number.isNaN(ms) ? null : Math.floor((nowMs - ms) / (1000 * 60 * 60 * 24));
42
+ continue;
43
+ }
44
+ if (!line || currentAge == null)
45
+ continue;
46
+ if (!line.endsWith(".md"))
47
+ continue;
48
+ // First (newest) sighting wins
49
+ if (!ages.has(line))
50
+ ages.set(line, currentAge);
51
+ }
52
+ return ages;
53
+ }
54
+ /**
55
+ * One `git log --name-only` for the whole docs/ tree. Docs histories are
56
+ * small even without --since (a large host's full docs log is ~5k lines / <100ms),
57
+ * so we take the full history for accurate ages on old files.
58
+ */
59
+ async function loadDocsAges(cwd) {
60
+ const result = await sh(`git log --format="COMMIT %aI" --name-only -- docs/`, {
61
+ cwd,
62
+ timeout: 120_000,
63
+ });
31
64
  if (result.exitCode !== 0 || !result.stdout.trim())
32
- return null;
33
- const ms = Date.now() - new Date(result.stdout.trim()).getTime();
34
- return Math.floor(ms / (1000 * 60 * 60 * 24));
65
+ return new Map();
66
+ return parseDocsAgeLog(result.stdout);
67
+ }
68
+ /** Age for a tracked path, or null if untracked / never under docs/. */
69
+ function ageDays(ages, rel) {
70
+ return ages.has(rel) ? ages.get(rel) : null;
35
71
  }
36
72
  /** Days since ANY commit in the repo (measures repo activity) */
37
73
  async function lastRepoCommitAgeDays(cwd) {
@@ -41,48 +77,36 @@ async function lastRepoCommitAgeDays(cwd) {
41
77
  const ms = Date.now() - new Date(result.stdout.trim()).getTime();
42
78
  return Math.floor(ms / (1000 * 60 * 60 * 24));
43
79
  }
44
- function readStatus(filePath) {
45
- try {
46
- const content = readFileSync(filePath, "utf8");
47
- const head = content.split("\n").slice(0, 20).join("\n");
48
- const m = head.match(/\*\*Status:\*\*\s*([a-zA-Z][a-zA-Z-]*)/);
49
- return m ? m[1].toLowerCase() : null;
50
- }
51
- catch {
52
- return null;
80
+ function walkMdFiles(dir, skipReadme = false) {
81
+ const out = [];
82
+ for (const entry of readdirSync(dir)) {
83
+ const full = join(dir, entry);
84
+ let st;
85
+ try {
86
+ st = statSync(full);
87
+ }
88
+ catch {
89
+ continue;
90
+ }
91
+ if (st.isDirectory()) {
92
+ out.push(...walkMdFiles(full, skipReadme));
93
+ }
94
+ else if (entry.endsWith(".md") && !(skipReadme && entry === "README.md")) {
95
+ out.push(full);
96
+ }
53
97
  }
98
+ return out;
54
99
  }
55
- async function sweepPlans(repoName, repoPath, items) {
100
+ function sweepPlans(repoName, repoPath, ages, items) {
56
101
  const plansDir = join(repoPath, "docs", "plans");
57
102
  if (!existsSync(plansDir))
58
103
  return;
59
- const walk = (dir) => {
60
- const out = [];
61
- for (const entry of readdirSync(dir)) {
62
- const full = join(dir, entry);
63
- let st;
64
- try {
65
- st = statSync(full);
66
- }
67
- catch {
68
- continue;
69
- }
70
- if (st.isDirectory()) {
71
- out.push(...walk(full));
72
- }
73
- else if (entry.endsWith(".md") && entry !== "README.md") {
74
- out.push(full);
75
- }
76
- }
77
- return out;
78
- };
79
- const files = walk(plansDir);
80
- for (const full of files) {
104
+ for (const full of walkMdFiles(plansDir, true)) {
81
105
  const rel = relative(repoPath, full);
82
106
  const displayPath = join(repoName === "(root)" ? "" : repoName, rel);
83
107
  const isArchived = rel.includes("/archive/");
84
- const status = readStatus(full);
85
- const age = await lastCommitAgeDays(repoPath, rel);
108
+ const status = readDocStatus(full, "plan");
109
+ const age = ageDays(ages, rel);
86
110
  if (age == null)
87
111
  continue;
88
112
  if (!isArchived && status === "in-progress" && age > STALLED_PLAN_DAYS) {
@@ -105,7 +129,7 @@ async function sweepPlans(repoName, repoPath, items) {
105
129
  }
106
130
  }
107
131
  }
108
- async function sweepIssues(repoName, repoPath, items) {
132
+ function sweepIssues(repoName, repoPath, ages, items) {
109
133
  const issuesDir = join(repoPath, "docs", "issues");
110
134
  if (!existsSync(issuesDir))
111
135
  return;
@@ -115,10 +139,10 @@ async function sweepIssues(repoName, repoPath, items) {
115
139
  const full = join(issuesDir, entry);
116
140
  const rel = join("docs", "issues", entry);
117
141
  const displayPath = join(repoName === "(root)" ? "" : repoName, rel);
118
- const status = readStatus(full);
142
+ const status = readDocStatus(full, "issue");
119
143
  if (status !== "open")
120
144
  continue;
121
- const age = await lastCommitAgeDays(repoPath, rel);
145
+ const age = ageDays(ages, rel);
122
146
  if (age == null || age <= OPEN_ISSUE_DAYS)
123
147
  continue;
124
148
  items.push({
@@ -130,36 +154,16 @@ async function sweepIssues(repoName, repoPath, items) {
130
154
  });
131
155
  }
132
156
  }
133
- async function sweepHandoffs(repoName, repoPath, items) {
157
+ function sweepHandoffs(repoName, repoPath, ages, items) {
134
158
  const handoffsDir = join(repoPath, "docs", "handoffs");
135
159
  if (!existsSync(handoffsDir))
136
160
  return;
137
- const walk = (dir) => {
138
- const out = [];
139
- for (const entry of readdirSync(dir)) {
140
- const full = join(dir, entry);
141
- let st;
142
- try {
143
- st = statSync(full);
144
- }
145
- catch {
146
- continue;
147
- }
148
- if (st.isDirectory()) {
149
- out.push(...walk(full));
150
- }
151
- else if (entry.endsWith(".md")) {
152
- out.push(full);
153
- }
154
- }
155
- return out;
156
- };
157
- for (const full of walk(handoffsDir)) {
161
+ for (const full of walkMdFiles(handoffsDir)) {
158
162
  const rel = relative(repoPath, full);
159
- const status = readStatus(full);
163
+ const status = readDocStatus(full, "handoff");
160
164
  if (status !== "open")
161
165
  continue;
162
- const age = await lastCommitAgeDays(repoPath, rel);
166
+ const age = ageDays(ages, rel);
163
167
  if (age == null || age <= COLD_HANDOFF_DAYS)
164
168
  continue;
165
169
  const displayPath = join(repoName === "(root)" ? "" : repoName, rel);
@@ -172,11 +176,11 @@ async function sweepHandoffs(repoName, repoPath, items) {
172
176
  });
173
177
  }
174
178
  }
175
- async function sweepRunbook(repoName, repoPath, items) {
179
+ function sweepRunbook(repoName, repoPath, ages, items) {
176
180
  const runbook = join(repoPath, "docs", "runbook.md");
177
181
  if (!existsSync(runbook))
178
182
  return;
179
- const age = await lastCommitAgeDays(repoPath, "docs/runbook.md");
183
+ const age = ageDays(ages, "docs/runbook.md");
180
184
  if (age == null || age <= RUNBOOK_DAYS)
181
185
  return;
182
186
  const displayPath = join(repoName === "(root)" ? "" : repoName, "docs/runbook.md");
@@ -188,17 +192,22 @@ async function sweepRunbook(repoName, repoPath, items) {
188
192
  message: `runbook hasn't been edited in ${age}d; re-verify procedures`,
189
193
  });
190
194
  }
191
- async function sweepTopicDocs(repoName, repoPath, items) {
195
+ function sweepTopicDocs(repoName, repoPath, ages, items) {
192
196
  const docsDir = join(repoPath, "docs");
193
197
  if (!existsSync(docsDir))
194
198
  return;
195
- // Skip known date-stamped or lifecycle-managed dirs
199
+ // Skip known date-stamped or lifecycle-managed dirs (and vendor dumps).
200
+ // handoffs/inquiries are lifecycle-managed elsewhere; vendors match the
201
+ // docs freshness scanner's IGNORE_DIRS.
196
202
  const skipDirs = new Set([
197
203
  "audits",
198
204
  "issues",
199
205
  "plans",
200
206
  "changelogs",
201
207
  "emails", // parent-specific
208
+ "handoffs",
209
+ "inquiries",
210
+ "vendors",
202
211
  ]);
203
212
  for (const entry of readdirSync(docsDir)) {
204
213
  if (skipDirs.has(entry))
@@ -213,28 +222,9 @@ async function sweepTopicDocs(repoName, repoPath, items) {
213
222
  }
214
223
  if (!st.isDirectory())
215
224
  continue;
216
- // For each topic dir, find .md files and check age
217
- const walk = (dir) => {
218
- const out = [];
219
- for (const f of readdirSync(dir)) {
220
- const fp = join(dir, f);
221
- let s;
222
- try {
223
- s = statSync(fp);
224
- }
225
- catch {
226
- continue;
227
- }
228
- if (s.isDirectory())
229
- out.push(...walk(fp));
230
- else if (f.endsWith(".md"))
231
- out.push(fp);
232
- }
233
- return out;
234
- };
235
- for (const full2 of walk(full)) {
225
+ for (const full2 of walkMdFiles(full)) {
236
226
  const rel = relative(repoPath, full2);
237
- const age = await lastCommitAgeDays(repoPath, rel);
227
+ const age = ageDays(ages, rel);
238
228
  if (age == null || age <= TOPIC_DOC_DAYS)
239
229
  continue;
240
230
  const displayPath = join(repoName === "(root)" ? "" : repoName, rel);
@@ -248,11 +238,11 @@ async function sweepTopicDocs(repoName, repoPath, items) {
248
238
  }
249
239
  }
250
240
  }
251
- async function sweepDecisions(repoName, repoPath, items) {
241
+ async function sweepDecisions(repoName, repoPath, ages, items) {
252
242
  const decisions = join(repoPath, "docs", "decisions.md");
253
243
  if (!existsSync(decisions))
254
244
  return;
255
- const age = await lastCommitAgeDays(repoPath, "docs/decisions.md");
245
+ const age = ageDays(ages, "docs/decisions.md");
256
246
  const repoAge = await lastRepoCommitAgeDays(repoPath);
257
247
  if (age == null || repoAge == null)
258
248
  return;
@@ -280,13 +270,17 @@ export async function runSweep(opts) {
280
270
  const filter = opts.repo === "." ? "(root)" : opts.repo;
281
271
  const filtered = filter ? targets.filter((t) => t.name === filter) : targets;
282
272
  const items = [];
283
- for (const { name, path } of filtered) {
284
- await sweepPlans(name, path, items);
285
- await sweepIssues(name, path, items);
286
- await sweepHandoffs(name, path, items);
287
- await sweepRunbook(name, path, items);
288
- await sweepTopicDocs(name, path, items);
289
- await sweepDecisions(name, path, items);
273
+ // Load ages per repo in parallel — one git log each, not one per file.
274
+ const ageMaps = await Promise.all(filtered.map((t) => loadDocsAges(t.path)));
275
+ for (let i = 0; i < filtered.length; i++) {
276
+ const { name, path } = filtered[i];
277
+ const ages = ageMaps[i];
278
+ sweepPlans(name, path, ages, items);
279
+ sweepIssues(name, path, ages, items);
280
+ sweepHandoffs(name, path, ages, items);
281
+ sweepRunbook(name, path, ages, items);
282
+ sweepTopicDocs(name, path, ages, items);
283
+ await sweepDecisions(name, path, ages, items);
290
284
  }
291
285
  // Sort by severity proxy: oldest first within kind
292
286
  items.sort((a, b) => b.ageDays - a.ageDays);
@@ -299,6 +293,7 @@ export async function runSweep(opts) {
299
293
  */
300
294
  export async function countColdHandoffs() {
301
295
  const items = [];
302
- await sweepHandoffs("(root)", REPO_ROOT, items);
296
+ const ages = await loadDocsAges(REPO_ROOT);
297
+ sweepHandoffs("(root)", REPO_ROOT, ages, items);
303
298
  return items.length;
304
299
  }
@@ -50,7 +50,7 @@ export function colorJson(value, indent = 0) {
50
50
  // Class instances that define toJSON() (Big.js, Decimal.js, Date, etc.)
51
51
  // would render as their raw internal shape if we walked Object.keys
52
52
  // directly. Unwrap once so callers see the intended representation
53
- // (Big.js → "1.97" instead of {s,e,c} from BigQuery NUMERIC fields).
53
+ // (e.g. Big.js → "1.97" instead of its internal {s,e,c} fields).
54
54
  const maybeJsonable = v;
55
55
  if (typeof maybeJsonable.toJSON === "function") {
56
56
  return fmt(maybeJsonable.toJSON(), depth);
@@ -109,7 +109,7 @@ function stringify(val) {
109
109
  if (typeof val === "object") {
110
110
  if (val instanceof Date)
111
111
  return val.toISOString();
112
- // BigQuery returns { value: "..." } for some types
112
+ // Some data sources wrap a scalar as { value: "..." }; unwrap to the inner value.
113
113
  if ("value" in val && Object.keys(val).length === 1) {
114
114
  return String(val.value);
115
115
  }
@@ -1,2 +1,3 @@
1
1
  export { type FetchOptions, type FetchResult, fetchWithJar } from "./client.js";
2
+ export * from "./request.js";
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/http/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/http/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChF,cAAc,cAAc,CAAC"}
@@ -1 +1,2 @@
1
1
  export { fetchWithJar } from "./client.js";
2
+ export * from "./request.js";
@@ -0,0 +1,77 @@
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
+ export interface RetryingResponse {
30
+ /** `status` in the 2xx range. */
31
+ ok: boolean;
32
+ status: number;
33
+ /** Response body as text; callers handle JSON parsing. */
34
+ text: string;
35
+ url: string;
36
+ headers: Headers;
37
+ }
38
+ export interface RequestWithRetriesOptions {
39
+ /** HTTP method. Default GET. */
40
+ method?: string;
41
+ /** Extra headers. Content-Type defaults to application/json when a non-string body is given. */
42
+ headers?: Record<string, string>;
43
+ /**
44
+ * Request body. Strings, Uint8Array, FormData, Blob, and ReadableStream pass
45
+ * through untouched; any other value is JSON.stringify'd (with a
46
+ * Content-Type: application/json default).
47
+ */
48
+ body?: unknown;
49
+ /** Per-attempt timeout (AbortController). Default 30s. */
50
+ timeoutMs?: number;
51
+ /** Retries after the first attempt. Default 3. */
52
+ maxRetries?: number;
53
+ /** Which statuses to retry. Default: 429 and 5xx. */
54
+ shouldRetry?: (status: number) => boolean;
55
+ /** Delay before retry `attempt` (0-based). Default `backoffDelayMs`. */
56
+ delayMs?: (attempt: number, retryAfterSeconds?: number | null) => number;
57
+ /** Observability hook — fires once per received response (every attempt). */
58
+ onResponse?: (info: {
59
+ method: string;
60
+ url: string;
61
+ status: number;
62
+ attempt: number;
63
+ }) => void;
64
+ /**
65
+ * Wrap a terminal network failure (fetch threw on the last attempt) in the
66
+ * caller's error class. Default: a plain Error with the message.
67
+ */
68
+ networkError?: (message: string, url: string, retries: number) => Error;
69
+ }
70
+ /**
71
+ * Exponential backoff with jitter: 500ms · 2^attempt, capped at 30s, plus up
72
+ * to 250ms of jitter. A sane `Retry-After` (0 < s < 60) short-circuits the
73
+ * curve — the server knows better than the guess.
74
+ */
75
+ export declare function backoffDelayMs(attempt: number, retryAfterSeconds?: number | null): number;
76
+ export declare function requestWithRetries(url: string, opts?: RequestWithRetriesOptions): Promise<RetryingResponse>;
77
+ //# sourceMappingURL=request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../../../src/lib/http/request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,iCAAiC;IACjC,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,yBAAyB;IACxC,gCAAgC;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gGAAgG;IAChG,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC;;;;OAIG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kDAAkD;IAClD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC;IAC1C,wEAAwE;IACxE,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,KAAK,MAAM,CAAC;IACzE,6EAA6E;IAC7E,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAC9F;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,KAAK,CAAC;CACzE;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAMzF;AAMD,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,yBAA8B,GACnC,OAAO,CAAC,gBAAgB,CAAC,CAkE3B"}