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.
- package/README.md +16 -6
- package/dist/commander.d.ts +29 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +4 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +94 -33
- package/dist/commands/browse-ai.js +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +41 -9
- package/dist/commands/cookies.js +1 -1
- package/dist/commands/decision.d.ts +4 -0
- package/dist/commands/decision.d.ts.map +1 -0
- package/dist/commands/decision.js +354 -0
- 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 +74 -2
- 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/fetch.js +1 -1
- 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/consume.d.ts +25 -2
- package/dist/core/agents/events/consume.d.ts.map +1 -1
- package/dist/core/agents/events/consume.js +55 -7
- package/dist/core/agents/events/emit.d.ts +2 -1
- package/dist/core/agents/events/emit.d.ts.map +1 -1
- package/dist/core/agents/events/emit.js +6 -1
- 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/agents/state/scratch.d.ts +1 -1
- package/dist/core/agents/state/scratch.js +2 -2
- 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 -17
- 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/agent-browser/client.js +1 -1
- package/dist/lib/browser/client.d.ts +14 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +20 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/runts.d.ts +44 -0
- package/dist/lib/browser/runts.d.ts.map +1 -0
- package/dist/lib/browser/runts.js +193 -0
- package/dist/lib/completion/walk.js +1 -1
- package/dist/lib/cookies/client.d.ts +1 -1
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +1 -1
- package/dist/lib/decision/index.d.ts +212 -0
- package/dist/lib/decision/index.d.ts.map +1 -0
- package/dist/lib/decision/index.js +523 -0
- 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 +3 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +67 -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 +15 -10
- 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 +9 -6
- package/src/commander.ts +35 -0
- package/src/commands/agents.ts +97 -29
- package/src/commands/browse-ai.ts +1 -1
- package/src/commands/browse.ts +63 -8
- package/src/commands/cookies.ts +1 -1
- package/src/commands/decision.ts +438 -0
- package/src/commands/deinit.ts +5 -0
- package/src/commands/devtools.ts +284 -0
- package/src/commands/docs.ts +86 -2
- package/src/commands/doctor.ts +13 -4
- package/src/commands/env.ts +11 -77
- package/src/commands/fetch.ts +1 -1
- 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/consume.ts +65 -7
- package/src/core/agents/events/emit.ts +7 -1
- package/src/core/agents/rules/claim-conflict.ts +17 -6
- package/src/core/agents/state/scratch.ts +2 -2
- package/src/core/config.ts +15 -1
- package/src/core/hooks/cli.ts +3 -3
- package/src/core/hooks/effects/index.ts +23 -16
- 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/agent-browser/client.ts +1 -1
- package/src/lib/browser/client.ts +28 -0
- package/src/lib/browser/index.ts +4 -0
- package/src/lib/browser/runts.ts +218 -0
- package/src/lib/completion/walk.ts +1 -1
- package/src/lib/cookies/client.ts +2 -2
- package/src/lib/decision/index.ts +685 -0
- 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 +61 -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 +15 -10
- 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
package/src/lib/docs-lint.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 { 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
|
|
175
|
-
function hasStatusHeader(path: string): boolean {
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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/")
|
|
391
|
+
const kind = rel.startsWith("docs/plans/")
|
|
392
|
+
? "plan"
|
|
393
|
+
: rel.startsWith("docs/issues/")
|
|
394
|
+
? "issue"
|
|
395
|
+
: "handoff";
|
|
347
396
|
violations.push({
|
|
348
|
-
severity: "
|
|
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
|
|
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
|
+
}
|
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