@pruddiman/hem 0.0.1-beta-5671db0
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/LICENSE +21 -0
- package/dist/agents/arbiter-agent.d.ts +72 -0
- package/dist/agents/arbiter-agent.js +149 -0
- package/dist/agents/architecture-agent.d.ts +148 -0
- package/dist/agents/architecture-agent.js +459 -0
- package/dist/agents/base-agent.d.ts +44 -0
- package/dist/agents/base-agent.js +57 -0
- package/dist/agents/crossref-agent.d.ts +140 -0
- package/dist/agents/crossref-agent.js +560 -0
- package/dist/agents/crossref-arbiter-agent.d.ts +72 -0
- package/dist/agents/crossref-arbiter-agent.js +147 -0
- package/dist/agents/documentation-agent.d.ts +55 -0
- package/dist/agents/documentation-agent.js +159 -0
- package/dist/agents/exploration-agent.d.ts +58 -0
- package/dist/agents/exploration-agent.js +102 -0
- package/dist/agents/grouping-agent.d.ts +167 -0
- package/dist/agents/grouping-agent.js +557 -0
- package/dist/agents/index-agent.d.ts +86 -0
- package/dist/agents/index-agent.js +360 -0
- package/dist/agents/organization-agent.d.ts +144 -0
- package/dist/agents/organization-agent.js +607 -0
- package/dist/auth.d.ts +372 -0
- package/dist/auth.js +1072 -0
- package/dist/broadcast-mcp.d.ts +21 -0
- package/dist/broadcast-mcp.js +59 -0
- package/dist/changelog.d.ts +85 -0
- package/dist/changelog.js +223 -0
- package/dist/decision-queue.d.ts +173 -0
- package/dist/decision-queue.js +265 -0
- package/dist/diff-scope.d.ts +24 -0
- package/dist/diff-scope.js +28 -0
- package/dist/discovery.d.ts +54 -0
- package/dist/discovery.js +405 -0
- package/dist/grouping.d.ts +37 -0
- package/dist/grouping.js +343 -0
- package/dist/helpers/format.d.ts +5 -0
- package/dist/helpers/format.js +13 -0
- package/dist/helpers/index.d.ts +11 -0
- package/dist/helpers/index.js +11 -0
- package/dist/helpers/parsing.d.ts +52 -0
- package/dist/helpers/parsing.js +128 -0
- package/dist/helpers/paths.d.ts +41 -0
- package/dist/helpers/paths.js +67 -0
- package/dist/helpers/strings.d.ts +45 -0
- package/dist/helpers/strings.js +97 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.js +1087 -0
- package/dist/merge-utils.d.ts +22 -0
- package/dist/merge-utils.js +34 -0
- package/dist/orchestrator.d.ts +194 -0
- package/dist/orchestrator.js +1169 -0
- package/dist/output.d.ts +106 -0
- package/dist/output.js +243 -0
- package/dist/progress.d.ts +228 -0
- package/dist/progress.js +644 -0
- package/dist/providers/copilot.d.ts +247 -0
- package/dist/providers/copilot.js +598 -0
- package/dist/providers/index.d.ts +15 -0
- package/dist/providers/index.js +12 -0
- package/dist/providers/opencode.d.ts +156 -0
- package/dist/providers/opencode.js +416 -0
- package/dist/providers/types.d.ts +156 -0
- package/dist/providers/types.js +16 -0
- package/dist/resources.d.ts +76 -0
- package/dist/resources.js +151 -0
- package/dist/search-index.d.ts +71 -0
- package/dist/search-index.js +187 -0
- package/dist/search-mcp.d.ts +25 -0
- package/dist/search-mcp.js +100 -0
- package/dist/server-utils.d.ts +56 -0
- package/dist/server-utils.js +135 -0
- package/dist/session.d.ts +227 -0
- package/dist/session.js +370 -0
- package/dist/types.d.ts +272 -0
- package/dist/types.js +5 -0
- package/dist/worktree.d.ts +82 -0
- package/dist/worktree.js +187 -0
- package/package.json +45 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone stdio-based MCP server for inter-agent broadcast communication.
|
|
4
|
+
*
|
|
5
|
+
* Exposes a single tool: `broadcast({ message: string })`
|
|
6
|
+
*
|
|
7
|
+
* This server is intentionally minimal. The real broadcast relay logic lives
|
|
8
|
+
* in the Hem orchestrator, which intercepts broadcast tool calls via SSE
|
|
9
|
+
* events and relays messages to peer agent sessions using `promptAsync()`.
|
|
10
|
+
*
|
|
11
|
+
* The server simply returns `{ status: "sent" }` to satisfy the agent's tool
|
|
12
|
+
* call — the orchestrator handles the actual message routing.
|
|
13
|
+
*
|
|
14
|
+
* Architecture:
|
|
15
|
+
* Agent calls broadcast() → MCP server returns "sent" →
|
|
16
|
+
* Orchestrator sees tool call in SSE → promptAsync() to all peers
|
|
17
|
+
*
|
|
18
|
+
* Registered in OpenCode via config.mcp as:
|
|
19
|
+
* { type: "local", command: ["node", "dist/broadcast-mcp.js"], enabled: true }
|
|
20
|
+
*/
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone stdio-based MCP server for inter-agent broadcast communication.
|
|
4
|
+
*
|
|
5
|
+
* Exposes a single tool: `broadcast({ message: string })`
|
|
6
|
+
*
|
|
7
|
+
* This server is intentionally minimal. The real broadcast relay logic lives
|
|
8
|
+
* in the Hem orchestrator, which intercepts broadcast tool calls via SSE
|
|
9
|
+
* events and relays messages to peer agent sessions using `promptAsync()`.
|
|
10
|
+
*
|
|
11
|
+
* The server simply returns `{ status: "sent" }` to satisfy the agent's tool
|
|
12
|
+
* call — the orchestrator handles the actual message routing.
|
|
13
|
+
*
|
|
14
|
+
* Architecture:
|
|
15
|
+
* Agent calls broadcast() → MCP server returns "sent" →
|
|
16
|
+
* Orchestrator sees tool call in SSE → promptAsync() to all peers
|
|
17
|
+
*
|
|
18
|
+
* Registered in OpenCode via config.mcp as:
|
|
19
|
+
* { type: "local", command: ["node", "dist/broadcast-mcp.js"], enabled: true }
|
|
20
|
+
*/
|
|
21
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
22
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
23
|
+
import { z } from "zod/v4";
|
|
24
|
+
const server = new McpServer({
|
|
25
|
+
name: "hem-broadcast",
|
|
26
|
+
version: "1.0.0",
|
|
27
|
+
});
|
|
28
|
+
server.registerTool("broadcast", {
|
|
29
|
+
description: "Broadcast a message to all other parallel organization workers. " +
|
|
30
|
+
"Use this to communicate file renames, deletions, structural conventions, " +
|
|
31
|
+
"and brief acknowledgements. Messages should be short and actionable.",
|
|
32
|
+
inputSchema: {
|
|
33
|
+
message: z
|
|
34
|
+
.string()
|
|
35
|
+
.describe("The message to broadcast. Use structured prefixes: " +
|
|
36
|
+
"RENAMED:, DELETED:, CONVENTION:, ACK:, SUGGESTION:"),
|
|
37
|
+
},
|
|
38
|
+
}, async ({ message }) => {
|
|
39
|
+
// The actual relay is handled by the orchestrator via SSE interception.
|
|
40
|
+
// This tool just needs to return success so the agent can continue.
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: "text",
|
|
45
|
+
text: JSON.stringify({ status: "sent", message }),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
async function main() {
|
|
51
|
+
const transport = new StdioServerTransport();
|
|
52
|
+
await server.connect(transport);
|
|
53
|
+
// stdout is the MCP transport — use stderr for logging
|
|
54
|
+
console.error("[hem-broadcast] MCP server running on stdio");
|
|
55
|
+
}
|
|
56
|
+
main().catch((err) => {
|
|
57
|
+
console.error("[hem-broadcast] Fatal:", err);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changelog generation and git-based diff scoping for Hem.
|
|
3
|
+
*
|
|
4
|
+
* Manages `changelog.md` in the documentation destination directory.
|
|
5
|
+
* Each Hem run appends a single entry recording the commit SHA, timestamp,
|
|
6
|
+
* and which doc files were created or updated.
|
|
7
|
+
*
|
|
8
|
+
* On subsequent runs, the previous SHA is extracted from `changelog.md`
|
|
9
|
+
* to compute which source files changed, enabling incremental generation.
|
|
10
|
+
*
|
|
11
|
+
* Reference: specs/004-changelog-diff-scope.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Reads the most recent commit SHA from the changelog file.
|
|
15
|
+
*
|
|
16
|
+
* Parses `{destinationPath}/changelog.md` for the first occurrence of
|
|
17
|
+
* `<!-- hem-sha: <SHA> -->`. Since entries are prepended (newest first),
|
|
18
|
+
* the first match is the most recent.
|
|
19
|
+
*
|
|
20
|
+
* @param destinationPath - Absolute or relative path to the docs directory.
|
|
21
|
+
* @returns The SHA string, or `null` if the file doesn't exist or has no marker.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readLastSHA(destinationPath: string): Promise<string | null>;
|
|
24
|
+
/**
|
|
25
|
+
* Computes which source files changed between a previous commit and HEAD.
|
|
26
|
+
*
|
|
27
|
+
* Runs `git diff --name-only <prevSHA>..HEAD` scoped to the source
|
|
28
|
+
* directory. Returns paths relative to `sourceRoot`. Downstream callers
|
|
29
|
+
* (e.g., `scopeToChangedFiles`) apply glob-level filtering, so this
|
|
30
|
+
* function returns all changed paths under `sourceRoot`.
|
|
31
|
+
*
|
|
32
|
+
* @param sourceRoot - Absolute path to the source directory.
|
|
33
|
+
* @param prevSHA - The commit SHA from the previous Hem run.
|
|
34
|
+
* @returns Array of relative paths of changed source files.
|
|
35
|
+
* @throws If git is not available, the repo is not a git repo, or the SHA is invalid.
|
|
36
|
+
*/
|
|
37
|
+
export declare function computeChangedFiles(sourceRoot: string, prevSHA: string): string[];
|
|
38
|
+
/**
|
|
39
|
+
* Returns the current HEAD commit SHA.
|
|
40
|
+
*
|
|
41
|
+
* @param sourceRoot - Absolute path to a directory within the git repo.
|
|
42
|
+
* @returns The full commit SHA string.
|
|
43
|
+
* @throws If git is not available or the directory is not in a git repo.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getCurrentSHA(sourceRoot: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Determines which doc files were created or modified during this Hem run.
|
|
48
|
+
*
|
|
49
|
+
* Uses `git status --porcelain` on the destination directory to find
|
|
50
|
+
* untracked (new) and modified files. This captures changes made by
|
|
51
|
+
* doc agents, post-processing agents, and structural file writes.
|
|
52
|
+
*
|
|
53
|
+
* @param destinationPath - Absolute path to the docs directory.
|
|
54
|
+
* @returns Array of relative paths of new/modified `.md` files.
|
|
55
|
+
*/
|
|
56
|
+
export declare function detectChangedDocs(destinationPath: string): string[];
|
|
57
|
+
/**
|
|
58
|
+
* Writes (or appends to) the changelog file with a new entry.
|
|
59
|
+
*
|
|
60
|
+
* Entries are prepended after the `# Changelog` heading so the most
|
|
61
|
+
* recent entry is always first. If the file doesn't exist, it is created
|
|
62
|
+
* with the heading.
|
|
63
|
+
*
|
|
64
|
+
* ### Entry format
|
|
65
|
+
*
|
|
66
|
+
* ```markdown
|
|
67
|
+
* ## 2026-02-21T15:30:00.000Z
|
|
68
|
+
* <!-- hem-sha: abc1234 -->
|
|
69
|
+
* - Updated: features/auth.md
|
|
70
|
+
* - Created: layers/database.md
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* For initial runs:
|
|
74
|
+
* ```markdown
|
|
75
|
+
* ## 2026-02-21T15:30:00.000Z
|
|
76
|
+
* <!-- hem-sha: abc1234 -->
|
|
77
|
+
* - Initial documentation generation
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* @param destinationPath - Absolute path to the docs directory.
|
|
81
|
+
* @param commitSHA - The HEAD commit SHA at the time of this run.
|
|
82
|
+
* @param docPaths - Relative paths of docs that were created/updated.
|
|
83
|
+
* @param isInitial - Whether this is the first Hem run (no prior changelog).
|
|
84
|
+
*/
|
|
85
|
+
export declare function writeChangelogEntry(destinationPath: string, commitSHA: string, docPaths: string[], isInitial: boolean): Promise<void>;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changelog generation and git-based diff scoping for Hem.
|
|
3
|
+
*
|
|
4
|
+
* Manages `changelog.md` in the documentation destination directory.
|
|
5
|
+
* Each Hem run appends a single entry recording the commit SHA, timestamp,
|
|
6
|
+
* and which doc files were created or updated.
|
|
7
|
+
*
|
|
8
|
+
* On subsequent runs, the previous SHA is extracted from `changelog.md`
|
|
9
|
+
* to compute which source files changed, enabling incremental generation.
|
|
10
|
+
*
|
|
11
|
+
* Reference: specs/004-changelog-diff-scope.
|
|
12
|
+
*/
|
|
13
|
+
import { resolve, join, relative } from "node:path";
|
|
14
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
15
|
+
import { execSync } from "node:child_process";
|
|
16
|
+
// ── SHA Marker ─────────────────────────────────────────────────────────
|
|
17
|
+
/**
|
|
18
|
+
* HTML comment pattern used to embed commit SHAs in changelog entries.
|
|
19
|
+
*
|
|
20
|
+
* Example: `<!-- hem-sha: abc1234def5678 -->`
|
|
21
|
+
*/
|
|
22
|
+
const SHA_MARKER_RE = /<!-- hem-sha: ([0-9a-f]+) -->/;
|
|
23
|
+
// ── Read Previous SHA ─────────────────────────────────────────────────
|
|
24
|
+
/**
|
|
25
|
+
* Reads the most recent commit SHA from the changelog file.
|
|
26
|
+
*
|
|
27
|
+
* Parses `{destinationPath}/changelog.md` for the first occurrence of
|
|
28
|
+
* `<!-- hem-sha: <SHA> -->`. Since entries are prepended (newest first),
|
|
29
|
+
* the first match is the most recent.
|
|
30
|
+
*
|
|
31
|
+
* @param destinationPath - Absolute or relative path to the docs directory.
|
|
32
|
+
* @returns The SHA string, or `null` if the file doesn't exist or has no marker.
|
|
33
|
+
*/
|
|
34
|
+
export async function readLastSHA(destinationPath) {
|
|
35
|
+
const changelogPath = join(resolve(destinationPath), "changelog.md");
|
|
36
|
+
let content;
|
|
37
|
+
try {
|
|
38
|
+
content = await readFile(changelogPath, "utf-8");
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const match = SHA_MARKER_RE.exec(content);
|
|
44
|
+
return match?.[1] ?? null;
|
|
45
|
+
}
|
|
46
|
+
// ── Compute Changed Files ─────────────────────────────────────────────
|
|
47
|
+
/**
|
|
48
|
+
* Computes which source files changed between a previous commit and HEAD.
|
|
49
|
+
*
|
|
50
|
+
* Runs `git diff --name-only <prevSHA>..HEAD` scoped to the source
|
|
51
|
+
* directory. Returns paths relative to `sourceRoot`. Downstream callers
|
|
52
|
+
* (e.g., `scopeToChangedFiles`) apply glob-level filtering, so this
|
|
53
|
+
* function returns all changed paths under `sourceRoot`.
|
|
54
|
+
*
|
|
55
|
+
* @param sourceRoot - Absolute path to the source directory.
|
|
56
|
+
* @param prevSHA - The commit SHA from the previous Hem run.
|
|
57
|
+
* @returns Array of relative paths of changed source files.
|
|
58
|
+
* @throws If git is not available, the repo is not a git repo, or the SHA is invalid.
|
|
59
|
+
*/
|
|
60
|
+
export function computeChangedFiles(sourceRoot, prevSHA) {
|
|
61
|
+
const absoluteSource = resolve(sourceRoot);
|
|
62
|
+
// Scope git diff to the source directory via `-- .`.
|
|
63
|
+
const cmd = `git diff --name-only ${prevSHA}..HEAD -- .`;
|
|
64
|
+
const output = execSync(cmd, {
|
|
65
|
+
cwd: absoluteSource,
|
|
66
|
+
encoding: "utf-8",
|
|
67
|
+
timeout: 15_000,
|
|
68
|
+
});
|
|
69
|
+
// `git diff --name-only` always returns paths relative to the repo root,
|
|
70
|
+
// regardless of cwd. We need to strip the source directory prefix so the
|
|
71
|
+
// returned paths are relative to sourceRoot (matching FileInfo.path).
|
|
72
|
+
const repoRoot = execSync("git rev-parse --show-toplevel", {
|
|
73
|
+
cwd: absoluteSource,
|
|
74
|
+
encoding: "utf-8",
|
|
75
|
+
timeout: 5_000,
|
|
76
|
+
}).trim();
|
|
77
|
+
const sourceRel = relative(repoRoot, absoluteSource).replace(/\\/g, "/");
|
|
78
|
+
return output
|
|
79
|
+
.split("\n")
|
|
80
|
+
.map((line) => line.trim())
|
|
81
|
+
.filter((line) => line.length > 0)
|
|
82
|
+
.map((line) => {
|
|
83
|
+
const normalized = line.replace(/\\/g, "/");
|
|
84
|
+
if (sourceRel && normalized.startsWith(sourceRel + "/")) {
|
|
85
|
+
return normalized.slice(sourceRel.length + 1);
|
|
86
|
+
}
|
|
87
|
+
return normalized;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// ── Get Current SHA ───────────────────────────────────────────────────
|
|
91
|
+
/**
|
|
92
|
+
* Returns the current HEAD commit SHA.
|
|
93
|
+
*
|
|
94
|
+
* @param sourceRoot - Absolute path to a directory within the git repo.
|
|
95
|
+
* @returns The full commit SHA string.
|
|
96
|
+
* @throws If git is not available or the directory is not in a git repo.
|
|
97
|
+
*/
|
|
98
|
+
export function getCurrentSHA(sourceRoot) {
|
|
99
|
+
return execSync("git rev-parse HEAD", {
|
|
100
|
+
cwd: resolve(sourceRoot),
|
|
101
|
+
encoding: "utf-8",
|
|
102
|
+
timeout: 5_000,
|
|
103
|
+
}).trim();
|
|
104
|
+
}
|
|
105
|
+
// ── Detect Changed Doc Files ──────────────────────────────────────────
|
|
106
|
+
/**
|
|
107
|
+
* Determines which doc files were created or modified during this Hem run.
|
|
108
|
+
*
|
|
109
|
+
* Uses `git status --porcelain` on the destination directory to find
|
|
110
|
+
* untracked (new) and modified files. This captures changes made by
|
|
111
|
+
* doc agents, post-processing agents, and structural file writes.
|
|
112
|
+
*
|
|
113
|
+
* @param destinationPath - Absolute path to the docs directory.
|
|
114
|
+
* @returns Array of relative paths of new/modified `.md` files.
|
|
115
|
+
*/
|
|
116
|
+
export function detectChangedDocs(destinationPath) {
|
|
117
|
+
const absoluteDest = resolve(destinationPath);
|
|
118
|
+
let output;
|
|
119
|
+
try {
|
|
120
|
+
output = execSync("git status --porcelain .", {
|
|
121
|
+
cwd: absoluteDest,
|
|
122
|
+
encoding: "utf-8",
|
|
123
|
+
timeout: 10_000,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Not a git repo or git not available — return empty
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const structural = new Set(["index.md", "architecture.md", "changelog.md"]);
|
|
131
|
+
return output
|
|
132
|
+
.split("\n")
|
|
133
|
+
.map((line) => line.trim())
|
|
134
|
+
.filter((line) => line.length > 0)
|
|
135
|
+
.map((line) => {
|
|
136
|
+
// git status --porcelain format: "XY <path>" or "XY <path> -> <path>"
|
|
137
|
+
// We want the path portion, which starts at index 3.
|
|
138
|
+
const pathPart = line.slice(3).trim();
|
|
139
|
+
// Handle renames: "old -> new"
|
|
140
|
+
const arrowIdx = pathPart.indexOf(" -> ");
|
|
141
|
+
return arrowIdx >= 0 ? pathPart.slice(arrowIdx + 4) : pathPart;
|
|
142
|
+
})
|
|
143
|
+
.filter((p) => p.endsWith(".md") && !structural.has(p));
|
|
144
|
+
}
|
|
145
|
+
// ── Write Changelog Entry ─────────────────────────────────────────────
|
|
146
|
+
/**
|
|
147
|
+
* Writes (or appends to) the changelog file with a new entry.
|
|
148
|
+
*
|
|
149
|
+
* Entries are prepended after the `# Changelog` heading so the most
|
|
150
|
+
* recent entry is always first. If the file doesn't exist, it is created
|
|
151
|
+
* with the heading.
|
|
152
|
+
*
|
|
153
|
+
* ### Entry format
|
|
154
|
+
*
|
|
155
|
+
* ```markdown
|
|
156
|
+
* ## 2026-02-21T15:30:00.000Z
|
|
157
|
+
* <!-- hem-sha: abc1234 -->
|
|
158
|
+
* - Updated: features/auth.md
|
|
159
|
+
* - Created: layers/database.md
|
|
160
|
+
* ```
|
|
161
|
+
*
|
|
162
|
+
* For initial runs:
|
|
163
|
+
* ```markdown
|
|
164
|
+
* ## 2026-02-21T15:30:00.000Z
|
|
165
|
+
* <!-- hem-sha: abc1234 -->
|
|
166
|
+
* - Initial documentation generation
|
|
167
|
+
* ```
|
|
168
|
+
*
|
|
169
|
+
* @param destinationPath - Absolute path to the docs directory.
|
|
170
|
+
* @param commitSHA - The HEAD commit SHA at the time of this run.
|
|
171
|
+
* @param docPaths - Relative paths of docs that were created/updated.
|
|
172
|
+
* @param isInitial - Whether this is the first Hem run (no prior changelog).
|
|
173
|
+
*/
|
|
174
|
+
export async function writeChangelogEntry(destinationPath, commitSHA, docPaths, isInitial) {
|
|
175
|
+
const absoluteDest = resolve(destinationPath);
|
|
176
|
+
const changelogPath = join(absoluteDest, "changelog.md");
|
|
177
|
+
const timestamp = new Date().toISOString();
|
|
178
|
+
// Build the new entry
|
|
179
|
+
const entryLines = [
|
|
180
|
+
`## ${timestamp}`,
|
|
181
|
+
`<!-- hem-sha: ${commitSHA} -->`,
|
|
182
|
+
];
|
|
183
|
+
if (isInitial) {
|
|
184
|
+
entryLines.push("- Initial documentation generation");
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
for (const docPath of docPaths.sort()) {
|
|
188
|
+
entryLines.push(`- ${docPath}`);
|
|
189
|
+
}
|
|
190
|
+
if (docPaths.length === 0) {
|
|
191
|
+
entryLines.push("- No documentation changes");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const entry = entryLines.join("\n");
|
|
195
|
+
// Read existing content (or start fresh)
|
|
196
|
+
let existing = "";
|
|
197
|
+
try {
|
|
198
|
+
existing = await readFile(changelogPath, "utf-8");
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// File doesn't exist — will be created
|
|
202
|
+
}
|
|
203
|
+
let newContent;
|
|
204
|
+
if (existing.length === 0) {
|
|
205
|
+
// Brand new file
|
|
206
|
+
newContent = `# Changelog\n\n${entry}\n`;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
// Prepend after the "# Changelog" heading line
|
|
210
|
+
const headingEnd = existing.indexOf("\n");
|
|
211
|
+
if (headingEnd >= 0) {
|
|
212
|
+
const heading = existing.slice(0, headingEnd);
|
|
213
|
+
const rest = existing.slice(headingEnd + 1);
|
|
214
|
+
newContent = `${heading}\n\n${entry}\n${rest}`;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
// File has content but no newline (unusual) — just prepend
|
|
218
|
+
newContent = `# Changelog\n\n${entry}\n`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
await mkdir(absoluteDest, { recursive: true });
|
|
222
|
+
await writeFile(changelogPath, newContent, "utf-8");
|
|
223
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision queue for Hem's parallel organization pass.
|
|
3
|
+
*
|
|
4
|
+
* Solves the race condition where the arbiter issues a MERGE and a DELETE
|
|
5
|
+
* for the same source file simultaneously. The MERGE must complete (worker
|
|
6
|
+
* reads source → writes merged target) before the DELETE removes the source.
|
|
7
|
+
*
|
|
8
|
+
* ## How it works
|
|
9
|
+
*
|
|
10
|
+
* 1. **MERGE src INTO dst** — relayed immediately. The queue records that
|
|
11
|
+
* `src` has a pending read (the merge worker needs to read it).
|
|
12
|
+
* A filesystem watcher on `dst` waits for the merged file to appear.
|
|
13
|
+
*
|
|
14
|
+
* 2. **DELETE filepath** — if `filepath` has a pending MERGE read, the
|
|
15
|
+
* DELETE is queued. Otherwise it's relayed immediately.
|
|
16
|
+
*
|
|
17
|
+
* 3. **UPDATE-LINKS / free-form** — always relayed immediately (graceful
|
|
18
|
+
* degradation for unparsed decisions).
|
|
19
|
+
*
|
|
20
|
+
* ## Release triggers
|
|
21
|
+
*
|
|
22
|
+
* - **Filesystem watcher** (`fs.watch`) detects when the merge target file
|
|
23
|
+
* is written, releasing queued DELETEs for the corresponding source.
|
|
24
|
+
* - **Worker completion** — when a worker's `promptAndWait` resolves, any
|
|
25
|
+
* decisions still blocked on that worker's actions are released.
|
|
26
|
+
* - **Timeout** — queued decisions are released after a configurable
|
|
27
|
+
* deadline (default 60 s) to prevent permanent blocking.
|
|
28
|
+
*
|
|
29
|
+
* The watcher uses `node:fs` callback-based `watch()` (not the async
|
|
30
|
+
* iterator from `node:fs/promises`) so we can `.close()` it cleanly.
|
|
31
|
+
*/
|
|
32
|
+
/** Default timeout (ms) after which a queued decision is released regardless. */
|
|
33
|
+
export declare const QUEUE_TIMEOUT_MS = 60000;
|
|
34
|
+
/**
|
|
35
|
+
* Parsed DECISION variants.
|
|
36
|
+
*
|
|
37
|
+
* - `merge` — MERGE <src> INTO <dst>
|
|
38
|
+
* - `delete` — DELETE <filepath>
|
|
39
|
+
* - `update-links` — UPDATE-LINKS <filepath>
|
|
40
|
+
* - `freeform` — anything else addressed to @org-worker-N
|
|
41
|
+
*/
|
|
42
|
+
export type DecisionAction = {
|
|
43
|
+
kind: "merge";
|
|
44
|
+
worker: string;
|
|
45
|
+
src: string;
|
|
46
|
+
dst: string;
|
|
47
|
+
} | {
|
|
48
|
+
kind: "delete";
|
|
49
|
+
worker: string;
|
|
50
|
+
filePath: string;
|
|
51
|
+
} | {
|
|
52
|
+
kind: "update-links";
|
|
53
|
+
worker: string;
|
|
54
|
+
filePath: string;
|
|
55
|
+
} | {
|
|
56
|
+
kind: "freeform";
|
|
57
|
+
worker: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Parse a DECISION message into a structured action.
|
|
61
|
+
* Returns `undefined` if the message is not a DECISION.
|
|
62
|
+
*
|
|
63
|
+
* NOTE: `@all-workers` decisions intentionally return `undefined` here
|
|
64
|
+
* because the regex only matches `@org-worker-\d+`. This is correct —
|
|
65
|
+
* `@all-workers` decisions are broadcast to all workers by the SSE relay
|
|
66
|
+
* routing logic and do not need file-based sequencing via DecisionQueue.
|
|
67
|
+
*/
|
|
68
|
+
export declare function parseDecision(message: string): DecisionAction | undefined;
|
|
69
|
+
/** A DELETE decision waiting for a MERGE to complete. */
|
|
70
|
+
export interface QueuedDecision {
|
|
71
|
+
/** Relay target (worker session). */
|
|
72
|
+
target: {
|
|
73
|
+
id: string;
|
|
74
|
+
label: string;
|
|
75
|
+
};
|
|
76
|
+
/** Full relay text to send when released. */
|
|
77
|
+
relayText: string;
|
|
78
|
+
/** Always "DELETE" — only DELETEs are queued. */
|
|
79
|
+
action: "DELETE";
|
|
80
|
+
/** The file this DELETE operates on (the merge source). */
|
|
81
|
+
filePath: string;
|
|
82
|
+
/** The merge target file we're waiting to see written. */
|
|
83
|
+
blockedBy: string;
|
|
84
|
+
/** Timestamp (ms) when the decision was queued. */
|
|
85
|
+
queuedAt: number;
|
|
86
|
+
/** Timeout handle — cleared when the decision is released. */
|
|
87
|
+
timer: ReturnType<typeof setTimeout>;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Callback that the queue invokes to relay a decision to a worker.
|
|
91
|
+
* The caller provides the actual `promptAsync` call.
|
|
92
|
+
*/
|
|
93
|
+
export type RelayFn = (target: {
|
|
94
|
+
id: string;
|
|
95
|
+
label: string;
|
|
96
|
+
}, relayText: string) => Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* Filesystem-watched decision queue.
|
|
99
|
+
*
|
|
100
|
+
* Instantiated once per `runParallel()` invocation. Call `start()` before
|
|
101
|
+
* the SSE relay loop and `stop()` in the `finally` block.
|
|
102
|
+
*/
|
|
103
|
+
export declare class DecisionQueue {
|
|
104
|
+
/** Files that have a pending MERGE read (source → merge-target). */
|
|
105
|
+
private pendingReads;
|
|
106
|
+
/** Queued DELETE decisions blocked on a pending MERGE. */
|
|
107
|
+
private queue;
|
|
108
|
+
/** The filesystem watcher (recursive), or null if not started. */
|
|
109
|
+
private watcher;
|
|
110
|
+
/** Absolute path to the documentation destination directory. */
|
|
111
|
+
private readonly destPath;
|
|
112
|
+
/** Timeout in ms for queued decisions. */
|
|
113
|
+
private readonly timeoutMs;
|
|
114
|
+
/** Relay callback provided by the caller. */
|
|
115
|
+
private readonly relay;
|
|
116
|
+
/** Optional verbose logger. */
|
|
117
|
+
private readonly verbose?;
|
|
118
|
+
constructor(opts: {
|
|
119
|
+
destPath: string;
|
|
120
|
+
relay: RelayFn;
|
|
121
|
+
timeoutMs?: number;
|
|
122
|
+
verbose?: (msg: string) => void;
|
|
123
|
+
});
|
|
124
|
+
/**
|
|
125
|
+
* Start the filesystem watcher on the destination directory.
|
|
126
|
+
* Must be called before processing any decisions.
|
|
127
|
+
*/
|
|
128
|
+
start(): void;
|
|
129
|
+
/**
|
|
130
|
+
* Stop the filesystem watcher and release all queued decisions.
|
|
131
|
+
* Call in the `finally` block of `runParallel()`.
|
|
132
|
+
*/
|
|
133
|
+
stop(): void;
|
|
134
|
+
/**
|
|
135
|
+
* Process a parsed DECISION and either relay it immediately or queue it.
|
|
136
|
+
*
|
|
137
|
+
* @param action - Parsed decision action.
|
|
138
|
+
* @param target - The worker session to relay to.
|
|
139
|
+
* @param relayText - Full relay text string.
|
|
140
|
+
*/
|
|
141
|
+
handleDecision(action: DecisionAction, target: {
|
|
142
|
+
id: string;
|
|
143
|
+
label: string;
|
|
144
|
+
}, relayText: string): Promise<void>;
|
|
145
|
+
/**
|
|
146
|
+
* Called when a worker completes. Releases any queued decisions that
|
|
147
|
+
* were blocked on actions by that worker (identified by label match
|
|
148
|
+
* in the target).
|
|
149
|
+
*/
|
|
150
|
+
releaseForWorker(workerLabel: string): void;
|
|
151
|
+
/**
|
|
152
|
+
* Returns the number of currently queued (blocked) decisions.
|
|
153
|
+
*/
|
|
154
|
+
get pendingCount(): number;
|
|
155
|
+
/**
|
|
156
|
+
* Returns a snapshot of pending reads (source → merge-target).
|
|
157
|
+
* Useful for testing.
|
|
158
|
+
*/
|
|
159
|
+
get pendingReadSnapshot(): ReadonlyMap<string, string>;
|
|
160
|
+
/** Enqueue a blocked DELETE decision. */
|
|
161
|
+
private enqueue;
|
|
162
|
+
/**
|
|
163
|
+
* Release a single queued decision — relay it and remove from queue.
|
|
164
|
+
*/
|
|
165
|
+
private release;
|
|
166
|
+
/** Release all queued decisions (used during stop/cleanup). */
|
|
167
|
+
private releaseAll;
|
|
168
|
+
/**
|
|
169
|
+
* Filesystem watcher callback. When a file in the destination directory
|
|
170
|
+
* changes, check if it matches a merge target and release blocked DELETEs.
|
|
171
|
+
*/
|
|
172
|
+
private onFileChange;
|
|
173
|
+
}
|