harnery 0.6.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.
- package/README.md +16 -6
- package/dist/commander.d.ts +19 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +51 -4
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/devtools.d.ts +4 -0
- package/dist/commands/devtools.d.ts.map +1 -0
- package/dist/commands/devtools.js +239 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +69 -1
- package/dist/commands/doctor.js +12 -4
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +3 -63
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +54 -14
- package/dist/commands/scratch.js +1 -1
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +273 -62
- package/dist/commands/web-fetch.js +1 -1
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +32 -8
- package/dist/core/agents/events/emit.d.ts.map +1 -1
- package/dist/core/agents/events/emit.js +4 -0
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +16 -5
- package/dist/core/config.d.ts +10 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +13 -0
- package/dist/core/hooks/cli.js +3 -3
- package/dist/core/hooks/effects/index.d.ts +11 -7
- package/dist/core/hooks/effects/index.d.ts.map +1 -1
- package/dist/core/hooks/effects/index.js +15 -18
- package/dist/core/hooks/events/emit.d.ts.map +1 -1
- package/dist/core/hooks/events/emit.js +4 -0
- package/dist/core/hooks/events/rotate.d.ts +43 -0
- package/dist/core/hooks/events/rotate.d.ts.map +1 -0
- package/dist/core/hooks/events/rotate.js +142 -0
- package/dist/core/hooks/harness/events.d.ts +11 -1
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +22 -3
- package/dist/core/hooks/harness/wiring.d.ts +8 -0
- package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
- package/dist/core/hooks/harness/wiring.js +34 -5
- package/dist/core/scratch/index.d.ts.map +1 -0
- package/dist/{lib → core}/scratch/index.js +2 -2
- package/dist/lib/devtools.d.ts +178 -0
- package/dist/lib/devtools.d.ts.map +1 -0
- package/dist/lib/devtools.js +1328 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter-migrate.js +364 -0
- package/dist/lib/docs-frontmatter.d.ts +33 -0
- package/dist/lib/docs-frontmatter.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter.js +130 -0
- package/dist/lib/docs-index.d.ts +1 -0
- package/dist/lib/docs-index.d.ts.map +1 -1
- package/dist/lib/docs-index.js +4 -5
- package/dist/lib/docs-lint.d.ts +2 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +18 -12
- package/dist/lib/docs-meta.d.ts +14 -0
- package/dist/lib/docs-meta.d.ts.map +1 -0
- package/dist/lib/docs-meta.js +34 -0
- package/dist/lib/docs-sweep.d.ts +12 -0
- package/dist/lib/docs-sweep.d.ts.map +1 -1
- package/dist/lib/docs-sweep.js +98 -103
- package/dist/lib/format.js +2 -2
- package/dist/lib/http/index.d.ts +1 -0
- package/dist/lib/http/index.d.ts.map +1 -1
- package/dist/lib/http/index.js +1 -0
- package/dist/lib/http/request.d.ts +77 -0
- package/dist/lib/http/request.d.ts.map +1 -0
- package/dist/lib/http/request.js +105 -0
- package/dist/lib/instructions/apply.d.ts +63 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -0
- package/dist/lib/instructions/apply.js +255 -0
- package/dist/lib/instructions/splice.d.ts +73 -0
- package/dist/lib/instructions/splice.d.ts.map +1 -0
- package/dist/lib/instructions/splice.js +118 -0
- package/dist/lib/instructions/templates.d.ts +45 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -0
- package/dist/lib/instructions/templates.js +258 -0
- package/dist/lib/tunnel/gate.d.ts +1 -0
- package/dist/lib/tunnel/gate.d.ts.map +1 -1
- package/dist/lib/tunnel/gate.js +14 -9
- package/dist/lib/tunnel/state.d.ts +11 -1
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +8 -3
- package/package.json +7 -6
- package/src/commander.ts +23 -0
- package/src/commands/agents.ts +50 -3
- package/src/commands/deinit.ts +5 -0
- package/src/commands/devtools.ts +284 -0
- package/src/commands/docs.ts +81 -1
- package/src/commands/doctor.ts +13 -4
- package/src/commands/env.ts +11 -77
- package/src/commands/init.ts +66 -15
- package/src/commands/scratch.ts +1 -1
- package/src/commands/tunnel.ts +316 -65
- package/src/commands/web-fetch.ts +1 -1
- package/src/core/agents/coord-client.ts +34 -7
- package/src/core/agents/events/emit.ts +5 -0
- package/src/core/agents/rules/claim-conflict.ts +17 -6
- package/src/core/config.ts +14 -0
- package/src/core/hooks/cli.ts +3 -3
- package/src/core/hooks/effects/index.ts +23 -17
- package/src/core/hooks/events/emit.ts +5 -0
- package/src/core/hooks/events/rotate.ts +151 -0
- package/src/core/hooks/harness/events.ts +30 -3
- package/src/core/hooks/harness/wiring.ts +46 -5
- package/src/{lib → core}/scratch/index.ts +2 -2
- package/src/lib/devtools.ts +1653 -0
- package/src/lib/docs-frontmatter-migrate.ts +427 -0
- package/src/lib/docs-frontmatter.ts +151 -0
- package/src/lib/docs-index.ts +4 -5
- package/src/lib/docs-lint.ts +17 -11
- package/src/lib/docs-meta.ts +44 -0
- package/src/lib/docs-sweep.ts +104 -102
- package/src/lib/format.ts +2 -2
- package/src/lib/http/index.ts +1 -0
- package/src/lib/http/request.ts +154 -0
- package/src/lib/instructions/apply.ts +318 -0
- package/src/lib/instructions/splice.ts +148 -0
- package/src/lib/instructions/templates.ts +295 -0
- package/src/lib/tunnel/gate.ts +14 -9
- package/src/lib/tunnel/state.ts +19 -4
- package/dist/lib/scratch/index.d.ts.map +0 -1
- /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
|
+
}
|
package/src/lib/docs-sweep.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
124
|
-
const age =
|
|
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
|
-
|
|
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 =
|
|
186
|
+
const status = readDocStatus(full, "issue");
|
|
158
187
|
if (status !== "open") continue;
|
|
159
|
-
const age =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
206
|
+
const status = readDocStatus(full, "handoff");
|
|
201
207
|
if (status !== "open") continue;
|
|
202
|
-
const age =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
236
|
+
function sweepTopicDocs(
|
|
231
237
|
repoName: string,
|
|
232
238
|
repoPath: string,
|
|
239
|
+
ages: AgeMap,
|
|
233
240
|
items: SweepItem[],
|
|
234
|
-
):
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
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}
|
|
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
|
-
//
|
|
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
|
}
|
package/src/lib/http/index.ts
CHANGED
|
@@ -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
|
+
}
|