@staix/agent-hub 0.12.44 → 0.13.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/CHANGELOG.md +40 -0
- package/README.md +83 -116
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +15 -0
- package/docs/agent-notes/adapters.md +63 -46
- package/docs/agent-notes/budget.md +7 -2
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +6 -4
- package/docs/agent-notes/tasks.md +18 -5
- package/docs/agent-notes/tests.md +11 -9
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +349 -26
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +12 -2
- package/docs/sdlc.md +61 -13
- package/docs/security.md +24 -12
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10120 -9623
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +32 -12
- package/src/adapters/codex-appserver.ts +19 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +25 -7
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +5 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +13 -1
- package/src/adapters/registry.ts +52 -15
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +36 -21
- package/src/cli/identity.ts +30 -7
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +145 -16
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +72 -11
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +28 -11
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +332 -61
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +45 -7
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +75 -2
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +48 -11
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +71 -18
- package/src/hub/settings.ts +10 -3
- package/src/hub/snapshots.ts +101 -14
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +556 -88
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +2 -2
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +560 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/cli/init.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { initShadow, repoOf } from "../hub/snapshots.ts";
|
|
2
3
|
import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "../hub/profiles.ts";
|
|
3
4
|
import { JCODE_MCP_FILE } from "./jcode-mcp.ts";
|
|
4
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import {
|
|
5
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync, renameSync } from "node:fs";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import type { Stats } from "node:fs";
|
|
9
|
+
import { realPath } from "../hub/project.ts";
|
|
10
|
+
import { hubGitArgv, hubGitEnv, hubGitSync } from "../hub/git.ts";
|
|
7
11
|
import { KIMI_MCP_FILE } from "./kimi-mcp.ts";
|
|
8
12
|
import { AGY_PLUGIN_FILES } from "./agy-mcp.ts";
|
|
9
13
|
|
|
@@ -36,8 +40,70 @@ export interface InitChange {
|
|
|
36
40
|
managedBlock?: "insert" | "replace" | "remove";
|
|
37
41
|
}
|
|
38
42
|
/** Content is internal to apply, never included in a public preview. */
|
|
39
|
-
interface PlannedWrite extends InitChange { next?: string; shadow?: true; }
|
|
40
|
-
|
|
43
|
+
interface PlannedWrite extends InitChange { next?: string; shadow?: true; directory?: true; executable?: true; moveFrom?: string; expected?: { dev: number; ino: number }; }
|
|
44
|
+
const RELEASE_BEGIN = "<!-- AGENT_HUB_RELEASE_GUARD:BEGIN (managed by `ahub init --release-guard`) -->";
|
|
45
|
+
const RELEASE_END = "<!-- AGENT_HUB_RELEASE_GUARD:END -->";
|
|
46
|
+
const HOOK_MARKER = "# agent-hub managed release pre-push guard";
|
|
47
|
+
const shellQuote = (value: string) => "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
|
48
|
+
|
|
49
|
+
function releaseBlock(existing: string): string {
|
|
50
|
+
const block = `${RELEASE_BEGIN}\n${readFileSync(join(TEMPLATES, "release-guard.block.md"), "utf8").trim()}\n${RELEASE_END}`;
|
|
51
|
+
const start = existing.indexOf("<!-- AGENT_HUB_RELEASE_GUARD:BEGIN"), end = existing.indexOf(RELEASE_END, Math.max(start, 0));
|
|
52
|
+
if (start >= 0 && end > start) return existing.slice(0, start) + block + existing.slice(end + RELEASE_END.length);
|
|
53
|
+
if (start >= 0 || existing.includes(RELEASE_END)) throw new Error("release guard instruction markers are incomplete");
|
|
54
|
+
return `${existing.trimEnd()}\n\n${block}\n`;
|
|
55
|
+
}
|
|
56
|
+
function regularHook(file: string): Stats | undefined {
|
|
57
|
+
const info = lstatSync(file, { throwIfNoEntry: false });
|
|
58
|
+
if (info && (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1)) throw new Error("release guard hook must be an unlinked regular file");
|
|
59
|
+
return info;
|
|
60
|
+
}
|
|
61
|
+
function releaseHookPlan(cwd: string): PlannedWrite[] {
|
|
62
|
+
for (const scope of [["--local"], ["--global"], []]) {
|
|
63
|
+
// Read the actual hooksPath without the hardened override masking it. All
|
|
64
|
+
// other portable Git hardening stays; `git config --get` executes no hook.
|
|
65
|
+
const argv = hubGitArgv(["config", ...scope, "--get", "core.hooksPath"]);
|
|
66
|
+
const override = argv.indexOf("core.hooksPath=/dev/null");
|
|
67
|
+
if (override < 1 || argv[override - 1] !== "-c") throw new Error("release guard Git hardening unavailable");
|
|
68
|
+
argv.splice(override - 1, 2);
|
|
69
|
+
const configured = spawnSync(argv[0]!, argv.slice(1), { cwd, env: hubGitEnv(process.env, { repositoryIsolation: true }), encoding: "utf8", timeout: 3000, maxBuffer: 16 * 1024 });
|
|
70
|
+
if (configured.status !== 1) throw new Error(configured.status === 0 ? "release guard refuses custom core.hooksPath; no files changed" : "release guard cannot verify core.hooksPath");
|
|
71
|
+
}
|
|
72
|
+
const common = hubGitSync(["rev-parse", "--git-common-dir"], { cwd, env: hubGitEnv(process.env, { repositoryIsolation: true }), encoding: "utf8", timeout: 3000 });
|
|
73
|
+
if (common.status !== 0 || !common.stdout.trim()) throw new Error("release guard requires a Git repository");
|
|
74
|
+
const raw = isAbsolute(common.stdout.trim()) ? common.stdout.trim() : resolve(cwd, common.stdout.trim());
|
|
75
|
+
const own = lstatSync(raw);
|
|
76
|
+
if (!own.isDirectory() || own.isSymbolicLink()) throw new Error("release guard Git directory must not be a link");
|
|
77
|
+
const commonDir = realPath(raw), hooks = join(commonDir, "hooks");
|
|
78
|
+
const parent = lstatSync(hooks, { throwIfNoEntry: false });
|
|
79
|
+
if (parent && (!parent.isDirectory() || parent.isSymbolicLink())) throw new Error("release guard hooks directory must not be a link");
|
|
80
|
+
const hook = join(hooks, "pre-push"), backup = join(hooks, "pre-push.agent-hub-original");
|
|
81
|
+
const original = regularHook(hook), saved = regularHook(backup);
|
|
82
|
+
const current = original ? readFileSync(hook, "utf8") : "";
|
|
83
|
+
const managed = current.startsWith(`#!/bin/sh\n${HOOK_MARKER}\n`);
|
|
84
|
+
if (!managed && saved) throw new Error("release guard backup is occupied; no files changed");
|
|
85
|
+
const wrapper = `#!/bin/sh
|
|
86
|
+
${HOOK_MARKER}
|
|
87
|
+
guard_input=$(umask 077; mktemp "\${TMPDIR:-/tmp}/ahub-release-guard.XXXXXX") || exit 1
|
|
88
|
+
trap 'rm -f "$guard_input"' 0
|
|
89
|
+
trap 'exit 1' 1 2 15
|
|
90
|
+
${shellQuote(process.execPath)} ${shellQuote(join(import.meta.dir, "main.ts"))} sdlc release-guard-hook "$1" "$2" >"$guard_input" || exit "$?"
|
|
91
|
+
if [ -x ${shellQuote(backup)} ]; then
|
|
92
|
+
${shellQuote(backup)} "$@" <"$guard_input"
|
|
93
|
+
else
|
|
94
|
+
exit 0
|
|
95
|
+
fi
|
|
96
|
+
`;
|
|
97
|
+
const plan: PlannedWrite[] = [];
|
|
98
|
+
if (!parent) plan.push({ action: "create", path: hooks, reason: "create default Git hooks directory", directory: true });
|
|
99
|
+
if (original && !managed) plan.push({ action: "create", path: backup, reason: "preserve original pre-push hook inode and mode", moveFrom: hook, expected: { dev: original.dev, ino: original.ino } });
|
|
100
|
+
if (current !== wrapper || original && !(original.mode & 0o111)) plan.push({ action: original ? "modify" : "create", path: hook, reason: "install release authorization pre-push guard", next: wrapper, executable: true });
|
|
101
|
+
return plan;
|
|
102
|
+
}
|
|
103
|
+
function changes(cwd: string, requested?: ProfileName, shadow = false, releaseGuard = false): PlannedWrite[] {
|
|
104
|
+
const originalAgents = existsSync(join(cwd, "AGENTS.md")) ? readFileSync(join(cwd, "AGENTS.md"), "utf8") : "";
|
|
105
|
+
const guarded = releaseGuard || originalAgents.includes("<!-- AGENT_HUB_RELEASE_GUARD:BEGIN");
|
|
106
|
+
const hooks = guarded ? releaseHookPlan(cwd) : [];
|
|
41
107
|
const repository = shadow ? repoOf(cwd) : undefined;
|
|
42
108
|
if (shadow && repository && !repository.shadow) throw new Error("project already has a Git work tree; shadow mode is for non-git folders");
|
|
43
109
|
if (shadow) {
|
|
@@ -80,7 +146,8 @@ function changes(cwd: string, requested?: ProfileName, shadow = false): PlannedW
|
|
|
80
146
|
}
|
|
81
147
|
const agents = join(cwd, "AGENTS.md");
|
|
82
148
|
const existing = existsSync(agents) ? readFileSync(agents, "utf8") : "";
|
|
83
|
-
|
|
149
|
+
const instructions = upsertBlock(existing, overrides && Object.hasOwn(overrides as object, "agents") ? profile.agents : profile.agents || readFileSync(join(TEMPLATES, "AGENTS.block.md"), "utf8"));
|
|
150
|
+
write(agents, guarded ? releaseBlock(instructions) : instructions, "refresh hub instructions; preserve surrounding text", existing.includes("<!-- AGENT_HUB:BEGIN") ? "replace" : "insert");
|
|
84
151
|
const claude = join(cwd, "CLAUDE.md");
|
|
85
152
|
const own = lstatSync(claude, { throwIfNoEntry: false });
|
|
86
153
|
// The write preserves an existing inode, including AGENTS.md -> CLAUDE.md and hard links.
|
|
@@ -105,19 +172,50 @@ function changes(cwd: string, requested?: ProfileName, shadow = false): PlannedW
|
|
|
105
172
|
}
|
|
106
173
|
if (existsSync(join(cwd, JCODE_MCP_FILE)) && hubGitSync(["ls-files", "--error-unmatch", "--", JCODE_MCP_FILE], { cwd, encoding: "utf8", timeout: 3000 }).status !== 0 && !lines.split("\n").includes(JCODE_MCP_FILE)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${JCODE_MCP_FILE}\n`;
|
|
107
174
|
write(ignore, lines, "ignore runtime state and machine-local configuration");
|
|
108
|
-
return changed;
|
|
175
|
+
return [...hooks, ...changed];
|
|
109
176
|
}
|
|
110
177
|
/** Read-only metadata, deliberately omitting project/user file contents. */
|
|
111
|
-
export function planInit(cwd: string, profile?: ProfileName, shadow = false): InitChange[] {
|
|
112
|
-
return changes(cwd, profile, shadow).map(({ next, shadow: _shadow, ...change }) => change);
|
|
178
|
+
export function planInit(cwd: string, profile?: ProfileName, shadow = false, releaseGuard = false): InitChange[] {
|
|
179
|
+
return changes(cwd, profile, shadow, releaseGuard).map(({ next, shadow: _shadow, directory, executable, moveFrom, expected, ...change }) => change);
|
|
113
180
|
}
|
|
114
181
|
/** Returns the paths it changed, applying the same plan as the preview. */
|
|
115
|
-
export function init(cwd: string, profile?: ProfileName, shadow = false): string[] {
|
|
116
|
-
const plan = changes(cwd, profile, shadow);
|
|
182
|
+
export function init(cwd: string, profile?: ProfileName, shadow = false, releaseGuard = false): string[] {
|
|
183
|
+
const plan = changes(cwd, profile, shadow, releaseGuard);
|
|
184
|
+
// Recheck hook parents and rename identities before any project write.
|
|
185
|
+
for (const change of plan.filter(change => change.executable || change.moveFrom)) {
|
|
186
|
+
const parent = lstatSync(dirname(change.path), { throwIfNoEntry: false });
|
|
187
|
+
if (parent && (!parent.isDirectory() || parent.isSymbolicLink())) throw new Error("release guard hook parent changed before installation");
|
|
188
|
+
if (change.moveFrom) {
|
|
189
|
+
const source = regularHook(change.moveFrom);
|
|
190
|
+
if (!source || source.dev !== change.expected?.dev || source.ino !== change.expected.ino || lstatSync(change.path, { throwIfNoEntry: false })) throw new Error("release guard hook changed before installation");
|
|
191
|
+
} else regularHook(change.path);
|
|
192
|
+
}
|
|
117
193
|
mkdirSync(join(cwd, ".agenthub"), { recursive: true });
|
|
118
194
|
for (const change of plan) {
|
|
119
195
|
if (change.shadow) initShadow(cwd);
|
|
196
|
+
else if (change.directory) mkdirSync(change.path);
|
|
197
|
+
else if (change.moveFrom) continue; // Applied atomically with its prepared wrapper below.
|
|
120
198
|
else if (change.action === "delete") unlinkSync(change.path);
|
|
199
|
+
else if (change.executable) {
|
|
200
|
+
const parent = lstatSync(dirname(change.path));
|
|
201
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error("release guard hook parent changed before installation");
|
|
202
|
+
const current = regularHook(change.path), move = plan.find(row => row.moveFrom === change.path);
|
|
203
|
+
const temporary = join(dirname(change.path), `.pre-push.${randomUUID()}.tmp`);
|
|
204
|
+
let moved = false;
|
|
205
|
+
try {
|
|
206
|
+
// Prepare bytes before disturbing any existing gate. Refreshing our own
|
|
207
|
+
// managed hook is one atomic rename, so a failed write leaves it intact.
|
|
208
|
+
writeFileSync(temporary, change.next!, { mode: 0o755, flag: "wx" });
|
|
209
|
+
if (move) {
|
|
210
|
+
if (!current || current.dev !== move.expected?.dev || current.ino !== move.expected.ino || lstatSync(move.path, { throwIfNoEntry: false })) throw new Error("release guard hook changed before installation");
|
|
211
|
+
renameSync(change.path, move.path); moved = true;
|
|
212
|
+
}
|
|
213
|
+
renameSync(temporary, change.path);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (moved && !lstatSync(change.path, { throwIfNoEntry: false })) renameSync(move!.path, change.path);
|
|
216
|
+
throw error;
|
|
217
|
+
} finally { if (lstatSync(temporary, { throwIfNoEntry: false })) unlinkSync(temporary); }
|
|
218
|
+
}
|
|
121
219
|
else writeFileSync(change.path, change.next!);
|
|
122
220
|
}
|
|
123
221
|
return plan.map(change => change.path);
|
package/src/cli/main.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { hubGitSync, hubGitEnv } from "../hub/git.ts";
|
|
3
|
+
import { parseReleaseGuardArgv, parseReleasePushUpdates } from "./sdlc-release-guard.ts";
|
|
4
|
+
import { validReleaseVersion } from "../hub/sdlc-release-authorization.ts";
|
|
5
|
+
import { agentVersions, runAgents } from "./agents.ts";
|
|
6
|
+
import { assessVersion, COMPAT, configuredCommands } from "../adapters/compat.ts";
|
|
2
7
|
import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
|
|
3
8
|
import { effectiveInstanceRoles } from "../hub/instances.ts";
|
|
4
9
|
import { resolveInstanceExecutionCwd } from "../hub/instance-execution.ts";
|
|
5
10
|
import { readInstanceRoleConfig, readInstanceConfig, type InstanceRoleConfig } from "../hub/instance-config.ts";
|
|
11
|
+
import { staleInstanceKey } from "../hub/roster.ts";
|
|
6
12
|
import { parseConfiguredAcpArguments, runConfiguredAcpEntry, requireActiveConfiguredAcp } from "./configured-acp-entry.ts";
|
|
7
13
|
import type { NativeCliEntryContext } from "../adapters/definitions/native-cli-entry-types.ts";
|
|
8
14
|
import { BUILTIN_CLI_ENTRIES, createProjectAdapterCatalog, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
|
|
@@ -73,14 +79,17 @@ import { selectedMemoryProfile } from "../hub/memory-profile.ts";
|
|
|
73
79
|
import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
|
|
74
80
|
import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
|
|
75
81
|
import { summarize, summarizeByTask } from "../hub/report.ts";
|
|
82
|
+
import { readDogfood } from "../hub/dogfood-read.ts";
|
|
83
|
+
import { dogfoodSince, formatDogfood } from "../hub/dogfood.ts";
|
|
84
|
+
import { USAGE_GROUPS, LIMITS_MAX } from "../hub/usage-rollup.ts";
|
|
76
85
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
77
86
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
78
87
|
import { COMPLETION_SHELLS, completionScript } from "./completion.ts";
|
|
79
|
-
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, LIVE_TEST_PERSON_REFUSAL, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
|
|
88
|
+
import { agentShellCause, classifyPeerCommand, cliCommandLabel, detectCliIdentity, LIVE_TEST_PERSON_REFUSAL, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
|
|
80
89
|
import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
|
|
81
90
|
import { runConsole } from "./console.ts";
|
|
82
91
|
import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
|
|
83
|
-
import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, type DoctorCheck } from "./output.ts";
|
|
92
|
+
import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
|
|
84
93
|
import { renderHelp, configuredAcpHelp } from "./help.ts";
|
|
85
94
|
import { renderTailEvent } from "./tail-render.ts";
|
|
86
95
|
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
@@ -443,7 +452,14 @@ async function taskOp(op: string, a: Record<string, unknown>, throwOnError = fal
|
|
|
443
452
|
try {
|
|
444
453
|
if (op === "hub_task_cancel" && !hub.features.includes("task_cancel")) fail("this daemon does not support task cancellation; upgrade it first");
|
|
445
454
|
const res = await hub.request({ t: "task", op, args: a }, timeoutMs ?? (op === "github_import" ? 60_000 : 30_000));
|
|
446
|
-
if (!res.ok) {
|
|
455
|
+
if (!res.ok) {
|
|
456
|
+
if (throwOnError) throw new Error(res.error);
|
|
457
|
+
audit("refused");
|
|
458
|
+
// #711: an agent shell refused something only the person may do is told why it counts as an agent. The refusal and the authority stay as they were.
|
|
459
|
+
const cause = agentShellCause(identity, String(res.error));
|
|
460
|
+
if (cause) console.error(`ahub: ${res.error}\n${cause}`), process.exit(1);
|
|
461
|
+
fail(res.error);
|
|
462
|
+
}
|
|
447
463
|
return res.text;
|
|
448
464
|
} finally { if (!connection) hub.close(); }
|
|
449
465
|
}
|
|
@@ -884,15 +900,15 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
884
900
|
},
|
|
885
901
|
|
|
886
902
|
init: () => {
|
|
887
|
-
const flags = takeFlags(args.filter(arg => arg !== "--dry-run" && arg !== "--json" && arg !== "--shadow"), ["--profile"], []);
|
|
888
|
-
if (flags.rest.length) fail("usage: ahub init [--profile dev|research|writing|general] [--shadow] [--dry-run --json]");
|
|
903
|
+
const flags = takeFlags(args.filter(arg => arg !== "--dry-run" && arg !== "--json" && arg !== "--shadow" && arg !== "--release-guard"), ["--profile"], []);
|
|
904
|
+
if (flags.rest.length) fail("usage: ahub init [--profile dev|research|writing|general] [--shadow] [--release-guard] [--dry-run --json]");
|
|
889
905
|
const requestedProfile = flags.one["--profile"] === undefined ? undefined : profileName(flags.one["--profile"]);
|
|
890
906
|
if (args.includes("--dry-run")) {
|
|
891
|
-
const plan = planInit(cwd, requestedProfile, args.includes("--shadow"));
|
|
907
|
+
const plan = planInit(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
|
|
892
908
|
return console.log(args.includes("--json") ? JSON.stringify(plan, null, 2) : plan.map(change => `${change.action} ${change.path}: ${change.reason}${change.managedBlock ? ` (managed block: ${change.managedBlock})` : ""}`).join("\n") || "already up to date");
|
|
893
909
|
}
|
|
894
910
|
assertLifecycleAvailable();
|
|
895
|
-
const changed = init(cwd, requestedProfile, args.includes("--shadow"));
|
|
911
|
+
const changed = init(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
|
|
896
912
|
registeredProject();
|
|
897
913
|
console.log(changed.length ? changed.map((p) => `${existsSync(p) ? "wrote" : "removed"} ${p}`).join("\n") : "already up to date");
|
|
898
914
|
const config = projectConfig(), examples = resolveProfile(config.profile, config.profile_overrides).example_checks;
|
|
@@ -1005,8 +1021,21 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1005
1021
|
printOutput(renderQueue(result.deliveries, options.columns, Date.now()), options.color);
|
|
1006
1022
|
return;
|
|
1007
1023
|
}
|
|
1024
|
+
if (operation === "discard") {
|
|
1025
|
+
// #713: the person's bulk clear of one peer's queue. Without --yes it only says how many it would discard.
|
|
1026
|
+
const yes = rest.includes("--yes");
|
|
1027
|
+
const { one, rest: others } = takeFlags(rest.filter((word) => word !== "--yes"), ["--peer", "--reason", "--state"], []);
|
|
1028
|
+
if (others.length || !one["--peer"] || (one["--state"] !== undefined && !["queued", "needs_review"].includes(one["--state"]))) fail("usage: ahub queue discard --peer <id> --reason <text> [--state queued|needs_review] [--yes]");
|
|
1029
|
+
if (yes && !one["--reason"]?.trim()) fail("ahub queue discard needs --reason <text> with --yes: it is recorded on every discarded delivery");
|
|
1030
|
+
const result = await hub.request({ t: "queue", op: "discard", peer: one["--peer"], ...(one["--state"] ? { state: one["--state"] } : {}), ...(yes ? { confirmed: true, reason: one["--reason"] } : {}) }, 60_000);
|
|
1031
|
+
if (!result.ok) fail(result.error);
|
|
1032
|
+
if (options.json) return console.log(JSON.stringify(result, null, 2));
|
|
1033
|
+
if (!yes) return console.log(`${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}; nothing discarded. Run again with --reason <text> --yes to discard them.`);
|
|
1034
|
+
console.log(`discarded ${result.discarded} of ${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}${result.refused ? `; ${result.refused} refused (${Object.entries(result.refusedBy ?? {}).map(([why, n]) => `${why} ${n}`).join(", ")})` : ""}`);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1008
1037
|
const [id, ...flags] = rest;
|
|
1009
|
-
if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text> [--revision N]");
|
|
1038
|
+
if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text> [--revision N] | discard --peer <id> --reason <text> [--state queued|needs_review] [--yes]");
|
|
1010
1039
|
if (operation === "show") {
|
|
1011
1040
|
if (flags.length) fail("usage: ahub queue show <id>");
|
|
1012
1041
|
const shown = await hub.request({ t: "queue", op: "show", id });
|
|
@@ -1191,6 +1220,50 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1191
1220
|
|
|
1192
1221
|
sdlc: async () => {
|
|
1193
1222
|
const sub = args[0], id = args[1] === undefined ? undefined : Number(args[1]);
|
|
1223
|
+
if (sub === "authorize-release") {
|
|
1224
|
+
if (args.length !== 2 || !validReleaseVersion(args[1])) fail("usage: ahub sdlc authorize-release <version>");
|
|
1225
|
+
const result = hubGitSync(["rev-parse", "--verify", "HEAD"], { cwd, env: hubGitEnv(process.env, { repositoryIsolation: true }), encoding: "utf8", timeout: 2000 });
|
|
1226
|
+
const commit = result.stdout?.trim();
|
|
1227
|
+
if (result.status !== 0 || !commit || !/^[a-f0-9]{40}$/.test(commit)) fail("candidate commit unavailable");
|
|
1228
|
+
const hub = await connect();
|
|
1229
|
+
try { const reply = await hub.request({ t: "sdlc", op: sub, version: args[1], commit }, 30_000); if (!reply.ok) fail(reply.error); console.log(JSON.stringify(reply.authorization, null, 2)); }
|
|
1230
|
+
finally { hub.close(); }
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (sub === "release-guard" || sub === "release-guard-hook") {
|
|
1234
|
+
let original: Buffer | undefined;
|
|
1235
|
+
let request: Record<string, unknown>;
|
|
1236
|
+
if (sub === "release-guard-hook") {
|
|
1237
|
+
if (args.length !== 3) fail("release hook needs its remote name and URL");
|
|
1238
|
+
const chunks: Buffer[] = []; let size = 0;
|
|
1239
|
+
for await (const chunk of process.stdin) { const bytes = Buffer.from(chunk); size += bytes.length; if (size > 128 * 1024) fail("release push inventory exceeds the guard bound"); chunks.push(bytes); }
|
|
1240
|
+
original = Buffer.concat(chunks);
|
|
1241
|
+
let input: string;
|
|
1242
|
+
try { input = new TextDecoder("utf-8", { fatal: true }).decode(original); } catch { fail("release push inventory is not UTF-8"); }
|
|
1243
|
+
const parsed = parseReleasePushUpdates(args[1]!, args[2]!, input);
|
|
1244
|
+
if (!parsed.ok) fail(parsed.reason);
|
|
1245
|
+
// Ordinary branch pushes keep working with the daemon offline.
|
|
1246
|
+
if (!parsed.updates.length) { process.stdout.write(original); return; }
|
|
1247
|
+
request = { t: "sdlc", op: sub, remoteName: parsed.remoteName, remoteUrl: parsed.remoteUrl, stdin: input };
|
|
1248
|
+
} else {
|
|
1249
|
+
if (args[1] !== "--") fail("usage: ahub sdlc release-guard -- git push origin v<version> | gh release create v<version> | npm publish");
|
|
1250
|
+
const argv = args.slice(2), parsed = parseReleaseGuardArgv(argv);
|
|
1251
|
+
if (!parsed.ok) fail(parsed.reason);
|
|
1252
|
+
// Literal preflights cannot attest a command redirected by ambient target selectors.
|
|
1253
|
+
const selectors = ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", ...(parsed.request.kind === "gh-release" ? ["GH_REPO", "GH_HOST"] : []), ...(parsed.request.kind === "npm-publish" ? Object.keys(process.env).filter(key => /^npm_config_(?:prefix|workspace|workspaces|userconfig|globalconfig|registry)$/i.test(key)) : [])];
|
|
1254
|
+
if (selectors.some(key => process.env[key] !== undefined)) fail("release guard refuses ambient target selectors");
|
|
1255
|
+
request = { t: "sdlc", op: sub, argv };
|
|
1256
|
+
}
|
|
1257
|
+
let hub: ControlClient | undefined;
|
|
1258
|
+
try {
|
|
1259
|
+
hub = await connect();
|
|
1260
|
+
const reply = await hub.request(request, 30_000);
|
|
1261
|
+
if (!reply.ok) fail(reply.error ?? "release authorization refused");
|
|
1262
|
+
if (original) process.stdout.write(original); else console.log("release authorization matched");
|
|
1263
|
+
} catch { fail("release authorization unavailable; the daemon must be reachable with a matching person approval"); }
|
|
1264
|
+
finally { hub?.close(); }
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1194
1267
|
if (sub === "queue" || sub === "release") {
|
|
1195
1268
|
const repo = args[1], liveIssues = args.slice(2).map(Number);
|
|
1196
1269
|
if (!repo || args.length > 34 || (sub === "queue" && args.length !== 2) || liveIssues.some(id => !Number.isSafeInteger(id) || id <= 0)) fail("usage: ahub sdlc queue <owner/repository> | release <owner/repository> [live-issue...]");
|
|
@@ -1230,6 +1303,17 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1230
1303
|
if (!flags.rest.length || (scope === undefined) !== (reviewers === undefined)) fail("plan-review needs a reason and both --review and --reviewers when supplying review scope");
|
|
1231
1304
|
return console.log(await taskOp("hub_plan_review", { id, revision, reason: flags.rest.join(" "), ...(scope !== undefined ? { review: { scope, reviewers: Number(reviewers) } } : {}), ...(flags.one["--ci"] !== undefined ? { ci: flags.one["--ci"] } : {}) }));
|
|
1232
1305
|
}
|
|
1306
|
+
if (sub === "accept-departure") {
|
|
1307
|
+
const head = args[2], rawReason = args.slice(3).join(" "), reason = rawReason.trim();
|
|
1308
|
+
if (!Number.isSafeInteger(id) || id! <= 0 || typeof head !== "string" || !/^[a-f0-9]{40}$/.test(head) || !reason || rawReason.length > 2048) fail("usage: ahub sdlc accept-departure <task> <head> <reason...>");
|
|
1309
|
+
const hub = await connect();
|
|
1310
|
+
try {
|
|
1311
|
+
const reply = await hub.request({ t: "sdlc", op: "accept-departure", id, head, reason }, 30_000);
|
|
1312
|
+
if (!reply.ok) fail(reply.error);
|
|
1313
|
+
console.log(`task #${id}: plan departure accepted for the reviewed head`);
|
|
1314
|
+
} finally { hub.close(); }
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1233
1317
|
if (sub === "accept") {
|
|
1234
1318
|
// #481 S1: the person accepts the task's spec issue at its current body; agent shells are refused before this runs.
|
|
1235
1319
|
const flags = takeFlags(args.slice(2), ["--issue", "--repo"], []);
|
|
@@ -1315,10 +1399,15 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1315
1399
|
if (args.length !== 2 || !/^\d+$/.test(args[1]!)) fail("usage: ahub review brief <task id>");
|
|
1316
1400
|
return console.log(await taskOp("review_brief", { id: Number(args[1]) }));
|
|
1317
1401
|
}
|
|
1318
|
-
const { one, many, rest } = takeFlags(args, ["--head"], ["--unmet"]);
|
|
1402
|
+
const { one, many, rest } = takeFlags(args, ["--head", "--findings"], ["--unmet"]);
|
|
1319
1403
|
const [id, verdict, ...note] = rest;
|
|
1320
|
-
if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]... | ahub review brief <id>");
|
|
1321
|
-
|
|
1404
|
+
if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>] [--findings <json>] | ahub review brief <id>");
|
|
1405
|
+
let findings: unknown;
|
|
1406
|
+
if (one["--findings"] !== undefined) {
|
|
1407
|
+
try { findings = JSON.parse(one["--findings"]); } catch { fail("--findings must be a JSON array"); }
|
|
1408
|
+
if (!Array.isArray(findings)) fail("--findings must be a JSON array");
|
|
1409
|
+
}
|
|
1410
|
+
console.log(await taskOp("hub_review", { id: Number(id), verdict, ...(one["--head"] ? { head: one["--head"] } : {}), note: note.join(" "), ...(findings === undefined ? {} : { findings }), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
|
|
1322
1411
|
},
|
|
1323
1412
|
|
|
1324
1413
|
remember: async () => console.log(await taskOp("hub_remember", { text: freeText(args, "ahub remember <text>") })),
|
|
@@ -1390,7 +1479,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1390
1479
|
roster: async () => {
|
|
1391
1480
|
const options = outputOptions(), flags = args.filter(arg => arg.startsWith("--")), positional = outputArguments().filter(arg => arg !== "--yes" && arg !== "--now");
|
|
1392
1481
|
const [sub = "list", peer, ...extra] = positional;
|
|
1393
|
-
if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !peerDefinition(peer)) || flags.some(flag => !["--json", "--full", "--yes", "--now"].includes(flag) && !flag.startsWith("--color=")) || (sub !== "list" && options.json) || (sub !== "remove" && flags.some(flag => ["--yes", "--now"].includes(flag)))) fail("usage: ahub roster [--json] | add <peer> | remove <peer> [--yes] [--now]");
|
|
1482
|
+
if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !peerDefinition(peer) && !(sub === "remove" && typeof peer === "string" && staleInstanceKey(peer, projectCatalog))) || flags.some(flag => !["--json", "--full", "--yes", "--now"].includes(flag) && !flag.startsWith("--color=")) || (sub !== "list" && options.json) || (sub !== "remove" && flags.some(flag => ["--yes", "--now"].includes(flag)))) fail("usage: ahub roster [--json] | add <peer> | remove <peer> [--yes] [--now]");
|
|
1394
1483
|
const hub = await connect();
|
|
1395
1484
|
try {
|
|
1396
1485
|
requireNamedPeerFeature(hub, peer);
|
|
@@ -1400,7 +1489,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1400
1489
|
if (!Array.isArray(view.roster)) fail("upgrade the running hub to use ahub roster");
|
|
1401
1490
|
return options.json ? console.log(JSON.stringify(view.roster, null, 2)) : printOutput(renderRoster(view.roster, options.columns), options.color);
|
|
1402
1491
|
}
|
|
1403
|
-
|
|
1492
|
+
// A key whose instance is no longer declared is deleted (null), never set to false: there is no peer left for it to switch off (#225).
|
|
1493
|
+
const key = `roster.${peer}`, stale = sub === "remove" && !peerDefinition(peer) && staleInstanceKey(peer!, projectCatalog), value = stale ? null : sub === "add";
|
|
1404
1494
|
const preview = await request({ t: "settings_preview", key, value });
|
|
1405
1495
|
printOutput((preview.lines ?? []).map((text: string) => [{ text }]), options.color);
|
|
1406
1496
|
if (sub === "remove" && !flags.includes("--yes")) {
|
|
@@ -1685,8 +1775,15 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1685
1775
|
const r = researchReport(records, from);
|
|
1686
1776
|
console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
|
|
1687
1777
|
},
|
|
1688
|
-
report: () => {
|
|
1778
|
+
report: async () => {
|
|
1689
1779
|
const options = outputOptions();
|
|
1780
|
+
if (args.includes("--dogfood")) {
|
|
1781
|
+
let from: number;
|
|
1782
|
+
try { from = dogfoodSince(args.filter(arg => !arg.startsWith("--color="))); }
|
|
1783
|
+
catch { return fail("usage: ahub report --dogfood [--since 7d|<iso>] [--json]"); }
|
|
1784
|
+
const scorecard = await readDogfood(cwd, stateDir, from);
|
|
1785
|
+
return console.log(options.json ? JSON.stringify(scorecard, null, 2) : formatDogfood(scorecard).join("\n"));
|
|
1786
|
+
}
|
|
1690
1787
|
const by = args.indexOf("--by");
|
|
1691
1788
|
if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
|
|
1692
1789
|
const events = readEvents(join(stateDir, "events.jsonl"), since());
|
|
@@ -1694,6 +1791,32 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1694
1791
|
if (options.json) return console.log(JSON.stringify(data, null, 2));
|
|
1695
1792
|
printOutput(renderReport(data, options.columns, Date.now(), by >= 0), options.color);
|
|
1696
1793
|
},
|
|
1794
|
+
usage: async () => {
|
|
1795
|
+
const options = outputOptions();
|
|
1796
|
+
const rest = outputArguments();
|
|
1797
|
+
const sub = rest[0] !== undefined && !rest[0].startsWith("--") ? rest[0] : undefined;
|
|
1798
|
+
if (sub !== undefined && !["limits", "total"].includes(sub)) fail("usage: ahub usage [--since 7d|<iso>] [--by peer|task|issue|pr|release|day|model] [--json] | limits [--peer <id>] [--limit <n>] [--json] | total [--json]");
|
|
1799
|
+
const flags = sub === undefined ? rest : rest.slice(1);
|
|
1800
|
+
const valueFlags = sub === "limits" ? ["--peer", "--limit"] : sub === undefined ? ["--since", "--by"] : [];
|
|
1801
|
+
for (let i = 0; i < flags.length; i++) {
|
|
1802
|
+
if (!valueFlags.includes(flags[i]!)) fail(`unknown argument ${JSON.stringify(flags[i]!.slice(0, 40))}; run ahub help`);
|
|
1803
|
+
if (flags[++i] === undefined || flags[i]!.startsWith("--")) fail(`${flags[i - 1]} needs a value`);
|
|
1804
|
+
}
|
|
1805
|
+
const value = (flag: string) => { const at = args.indexOf(flag); return at >= 0 ? args[at + 1] : undefined; };
|
|
1806
|
+
const by = value("--by"), peer = value("--peer"), limitText = value("--limit");
|
|
1807
|
+
const limit = limitText === undefined ? undefined : /^\d+$/.test(limitText) ? Number(limitText) : NaN;
|
|
1808
|
+
if (limit !== undefined && !(limit >= 1 && limit <= LIMITS_MAX)) fail(`--limit takes a whole number from 1 to ${LIMITS_MAX}`);
|
|
1809
|
+
if (by !== undefined && !(USAGE_GROUPS as readonly string[]).includes(by)) fail(`--by takes ${USAGE_GROUPS.join(", ")}`);
|
|
1810
|
+
const from = since();
|
|
1811
|
+
const hub = await connect();
|
|
1812
|
+
const reply = await hub.request(sub === "limits" ? { t: "usage", op: "limits", ...(peer ? { peer } : {}), ...(limit !== undefined ? { limit } : {}) } : sub === "total" ? { t: "usage", op: "total" }
|
|
1813
|
+
: { t: "usage", op: "query", by: by ?? "peer", ...(from ? { since: from } : {}) }, 35_000);
|
|
1814
|
+
hub.close();
|
|
1815
|
+
if (!reply.ok) return fail(String(reply.error ?? "usage refused"));
|
|
1816
|
+
if (reply.behind) process.stderr.write("ahub: the usage roll-up is still catching up with the event log: these numbers are partial; run it again in a few seconds\n");
|
|
1817
|
+
if (options.json) return console.log(JSON.stringify(sub === "limits" ? { limits: reply.limits, current: reply.current } : sub === "total" ? reply.total : reply.usage, null, 2));
|
|
1818
|
+
printOutput(sub === "limits" ? renderLimits(reply.limits, reply.current, options.columns) : sub === "total" ? renderTotal(reply.total, options.columns) : renderUsage(reply.usage, options.columns), options.color);
|
|
1819
|
+
},
|
|
1697
1820
|
facts: async () => {
|
|
1698
1821
|
if (!args.includes("--hook")) return fail("usage: ahub facts --hook (a Claude Code PreToolUse, PostToolUse and Stop hook)");
|
|
1699
1822
|
try {
|
|
@@ -1931,6 +2054,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1931
2054
|
}
|
|
1932
2055
|
fail("usage: ahub role list [--group <group>] | show <id> | assign <peer> <id> | clear <peer> [<id>]");
|
|
1933
2056
|
},
|
|
2057
|
+
agents: () => runAgents(args, configuredCommands(loadConfig(cwd)), fail),
|
|
2058
|
+
|
|
1934
2059
|
doctor: async () => {
|
|
1935
2060
|
const options = outputOptions();
|
|
1936
2061
|
if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
|
|
@@ -1983,10 +2108,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1983
2108
|
[servers.length ? `${servers.length} besides the hub's own would load in the hub-owned jcode (${servers.join(", ")}); jcode has no switch to choose them, so remove them from its config or use another JCODE_HOME` : "",
|
|
1984
2109
|
unreadable.length ? `unreadable or unsafe, not listed: ${unreadable.join(", ")}` : ""].filter(Boolean).join("; ") || "none besides the hub's own");
|
|
1985
2110
|
} else {
|
|
1986
|
-
const
|
|
1987
|
-
row(
|
|
2111
|
+
const verdict = assessVersion(probe.compat, COMPAT[probe.compat].parse(version(probe.command[0]!) ?? ""));
|
|
2112
|
+
row(verdict.ok ? verdict.state === "verified" || undefined : false, probe.name, verdict.ok ? verdict.note ?? probe.confirmed : `${probe.unconfirmed}: ${verdict.reason}`);
|
|
1988
2113
|
}
|
|
1989
2114
|
}
|
|
2115
|
+
// #691: one line for each installed gated CLI that is not on a verified version.
|
|
2116
|
+
for (const agent of agentVersions(configuredCommands(config))) {
|
|
2117
|
+
if (agent.executable && !(agent.verdict.ok && agent.verdict.state === "verified")) row(agent.verdict.ok ? undefined : false, `${agent.id} version`, agent.verdict.ok ? agent.verdict.note ?? agent.verdict.state : agent.verdict.reason);
|
|
2118
|
+
}
|
|
1990
2119
|
const omni = new OmniRoute(config.omniroute);
|
|
1991
2120
|
const gateway = await omni.base();
|
|
1992
2121
|
row(gateway ? true : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? false : undefined, "omniroute", gateway ? `${new URL(gateway).host} healthy` : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? "no candidate reachable (VPN off?); ahub local cannot run" : "not configured: set omniroute.urls in .agenthub/config.local.json (any OpenAI-compatible gateway); ahub local cannot run");
|
package/src/cli/output.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, termin
|
|
|
5
5
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
6
6
|
import { backendLabel } from "./status-lines.ts";
|
|
7
7
|
import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
|
|
8
|
+
import type { Counter, LimitsResult, TotalResult, UsageResult } from "../hub/usage-rollup.ts";
|
|
8
9
|
|
|
9
10
|
export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
|
|
10
11
|
export interface StatusOutput {
|
|
@@ -17,7 +18,7 @@ export interface StatusOutput {
|
|
|
17
18
|
isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
|
|
18
19
|
memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
|
|
19
20
|
taskWip?: Record<string, { wip: number; wipLimit: number }>;
|
|
20
|
-
pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
|
|
21
|
+
pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; actionPolicyInfo?: Record<string, { note: string; enforceable: boolean }>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
|
|
21
22
|
tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
|
|
22
23
|
}
|
|
23
24
|
export interface BudgetOutput { budget: Record<string, any>; gate?: number }
|
|
@@ -245,9 +246,12 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
245
246
|
}
|
|
246
247
|
if (p.toolsOnly) out.push(...detail(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") && p.pullOnly ? "pull-only" : "tools-only", flat(p.toolsOnly).replace(/^(?:tools-only|pull-only):\s*/, ""), columns, "attention"));
|
|
247
248
|
if (p.pullOnly && definitionOf(id)?.attach?.pullOnlyForms?.includes("tui")) out.push(...detail(`${id} last read`, typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
|
|
249
|
+
const actionPolicy = data.actionPolicyInfo?.[id];
|
|
250
|
+
if (actionPolicy?.note) out.push(...detail(`${id} action policy`, actionPolicy.note, columns, actionPolicy.enforceable ? "muted" : "attention"));
|
|
248
251
|
if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
|
|
249
252
|
if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
|
|
250
253
|
if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
|
|
254
|
+
if (typeof p.modelSource === "string" && p.modelSource) out.push(...detail("model source", flat(p.modelSource), columns, "muted")); // an extra Jcode instance (#685): names only, never a URL or token
|
|
251
255
|
if (typeof p.importantWaitingSince === "number" && p.importantWaitingNote) out.push(...detail("important waiting", `${p.importantWaitingNote.replace(/^\d+ important messages? waiting/, "important messages waiting")}`, columns, "attention"));
|
|
252
256
|
if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
|
|
253
257
|
return out;
|
|
@@ -431,3 +435,42 @@ export function renderReport(report: Report | TaskReport, columns?: number, now
|
|
|
431
435
|
}
|
|
432
436
|
return out;
|
|
433
437
|
}
|
|
438
|
+
|
|
439
|
+
// ---- ahub usage (#683 U2): every number names its source; an unknown number prints `unknown`, never 0 ----------------------------------------------
|
|
440
|
+
const SOURCE_NAME: Record<Counter["source"], string> = { native: "native", count: "hub count", manual: "manual", partial: "partial, at least", unknown: "unknown" };
|
|
441
|
+
/** `1234 (native)`, `1234 (hub count)`, or `unknown`: a missing counter is never shown as zero. */
|
|
442
|
+
export const usageNumber = (c: Counter): string => c.value === null ? "unknown" : `${c.value} (${SOURCE_NAME[c.source]})`;
|
|
443
|
+
const usageCell = (c: Counter): OutputCell => c.value === null ? span("unknown", "muted") : span(usageNumber(c), "number");
|
|
444
|
+
|
|
445
|
+
export function renderUsage(result: UsageResult, columns?: number): Span[][] {
|
|
446
|
+
const out = lines(`usage by ${result.by}: ${result.coverage.records} records, ${result.coverage.withUsage} with provider counters, ${result.coverage.withoutUsage} without`, columns, "muted");
|
|
447
|
+
const header = ["KEY", "TOTAL", "WITHOUT CACHE", "CACHE READ", "INPUT", "OUTPUT", "REASONING", "HUB COUNT"];
|
|
448
|
+
const row = (r: UsageResult["rows"][number]) => [span(r.pii ? `${r.key} [pii]` : r.key), usageCell(r.total), usageCell(r.totalWithoutCache), usageCell(r.cacheRead), usageCell(r.input), usageCell(r.output), usageCell(r.reasoning), usageCell(r.counted)];
|
|
449
|
+
const all = [...result.rows, ...(result.unattributed ? [result.unattributed] : [])];
|
|
450
|
+
if (!all.length) return [...out, ...lines("no usage recorded in this period", columns)];
|
|
451
|
+
return [...out, [], ...rows(header, all.map(row), columns, undefined, true),
|
|
452
|
+
...lines("TOTAL is the provider's own total, cache reads included; WITHOUT CACHE removes them. HUB COUNT is what the hub counted itself and covers peers that report no usage record. unknown is never zero.", columns, "muted")];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function renderLimits(result: LimitsResult, current: unknown, columns?: number): Span[][] {
|
|
456
|
+
const out = lines(`limit history: ${result.truncated ? `the newest ${result.shown} of ${result.total}` : `${result.total}`} events, ${result.open.length} open pause${result.open.length === 1 ? "" : "s"}`, columns, "muted");
|
|
457
|
+
if (result.truncated) out.push(...lines(`${result.omitted} older events are not shown: use --limit <n> (up to ${result.maxLimit}) for more`, columns, "attention"));
|
|
458
|
+
for (const o of result.open) out.push(...lines(`open pause: ${o.peer} ${o.window} since ${o.since}, resets ${new Date(o.resetsAt).toISOString()}`, columns, "attention"));
|
|
459
|
+
for (const o of result.endedUnknown) out.push(...lines(`pause ended, end unknown: ${o.peer} ${o.window} since ${o.since} (the hub no longer holds it, for example after ahub reset; no resume was recorded)`, columns, "muted"));
|
|
460
|
+
const windows = current && typeof current === "object" ? Object.entries(current as Record<string, { windows?: { id: string; used: number; resetsAt?: number }[] }>) : [];
|
|
461
|
+
const live = windows.flatMap(([peer, b]) => (b?.windows ?? []).map(w => [span(peer), span(w.id), span(`${Math.round(w.used * 100)}%`, "number"), span(w.resetsAt ? new Date(w.resetsAt).toISOString() : "unknown", w.resetsAt ? undefined : "muted")]));
|
|
462
|
+
if (live.length) out.push([], ...lines("current windows", columns, "strong"), ...rows(["PEER", "WINDOW", "USED", "RESETS"], live, columns, undefined, true));
|
|
463
|
+
const hist = result.history.map(h => [span(h.at), span(h.type.replace("budget_", "").replace("quota_reset", "reset")), span(h.peer), span(h.window), span(Object.entries(h.detail).map(([k, v]) => `${k} ${v}`).join(", "), "muted")]);
|
|
464
|
+
if (hist.length) out.push([], ...lines("history", columns, "strong"), ...rows(["AT", "EVENT", "PEER", "WINDOW", "DETAIL"], hist, columns, undefined, true));
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function renderTotal(result: TotalResult, columns?: number): Span[][] {
|
|
469
|
+
const out = lines(`lifetime development tokens since ${result.since ?? "the first event"}`, columns, "muted");
|
|
470
|
+
const table = result.peers.map(p => [span(p.peer), usageCell(p.tokens), usageCell(p.withoutCache), usageCell(p.cacheRead)]);
|
|
471
|
+
table.push([span("ALL PEERS", "strong"), usageCell(result.overall.tokens), usageCell(result.overall.withoutCache), usageCell(result.overall.cacheRead)]);
|
|
472
|
+
const left = (label: string, peers: string[]) => peers.length ? lines(`${label} leaves out ${peers.join(", ")} (unknown, not counted as 0): a lower bound`, columns, "attention") : [];
|
|
473
|
+
return [...out, [], ...rows(["PEER", "TOTAL", "WITHOUT CACHE", "CACHE READ"], table, columns, undefined, true),
|
|
474
|
+
...left("ALL PEERS TOTAL", result.overall.tokens.source === "partial" ? result.unknownPeers.tokens : []), ...left("ALL PEERS CACHE READ", result.overall.cacheRead.source === "partial" ? result.unknownPeers.cacheRead : []),
|
|
475
|
+
...lines("A peer with no provider counters (for example jcode, kimi) shows the hub's own count where one exists, otherwise unknown. The overall WITHOUT CACHE is unknown unless every peer's cache split is known.", columns, "muted")];
|
|
476
|
+
}
|
package/src/cli/preview.ts
CHANGED
|
@@ -21,7 +21,7 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen
|
|
|
21
21
|
if (mode >= 0 && args[mode + 1] !== "headless") throw new Error("extra instances require headless mode");
|
|
22
22
|
if (mode < 0 && !args.includes("--headless")) args.push("--mode", "headless");
|
|
23
23
|
}
|
|
24
|
-
const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition,
|
|
24
|
+
const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition, ...(selected.instance ? { instance: selected.instance } : {}),
|
|
25
25
|
baseEnvNames: Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]),
|
|
26
26
|
helpers: { buildLaunch: (native, argv, ctx) => buildLaunch(native, argv, { ...ctx, ...(selected.instance ? { instance: selected.instance } : {}) }), claudeObservationHooks, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, childEnv,
|
|
27
27
|
startModeOf, kimiMcpPlan, jcodeCommand, jcodeTuiCommand, qwenSessionId, relayModelIds, buildPiLaunch },
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Literal release targets only. Parsing never executes a command or reads the repository. */
|
|
2
|
+
export type ReleaseGuardRequest =
|
|
3
|
+
| { kind: "tag-push"; version: string; tag: string; sourceRef: string; remote: string }
|
|
4
|
+
| { kind: "gh-release"; version: string; tag: string; target?: string }
|
|
5
|
+
| { kind: "npm-publish" };
|
|
6
|
+
export interface ReleasePushUpdate { localRef: string; localOid: string; remoteRef: string; remoteOid: string; version: string; }
|
|
7
|
+
type Refusal = { ok: false; reason: string };
|
|
8
|
+
export type ReleaseGuardParse = { ok: true; request: ReleaseGuardRequest } | Refusal;
|
|
9
|
+
export type ReleasePushParse = { ok: true; remoteName: string; remoteUrl: string; updates: ReleasePushUpdate[]; stdin: string } | Refusal;
|
|
10
|
+
const refuse = (): Refusal => ({ ok: false, reason: "release target is ambiguous, unsupported or invalid" });
|
|
11
|
+
const SEMVER = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:[+][0-9A-Za-z.-]+)?$/;
|
|
12
|
+
const oid = (value: string) => /^[0-9a-f]{40}$/.test(value);
|
|
13
|
+
const zero = (value: string) => /^0{40}$/.test(value);
|
|
14
|
+
const literal = (value: string) => value.length > 0 && value.length <= 4096 && !/[\s\0-\x1f\x7f$`*?\\]/.test(value);
|
|
15
|
+
const ref = (value: string) => literal(value) && !value.startsWith("-") && !value.includes("..") && !value.includes("@{") && !value.includes("//") && !value.endsWith("/") && !value.endsWith(".") && !value.endsWith(".lock") && !"~^:[]()".split("").some(char => value.includes(char));
|
|
16
|
+
const versionOf = (tag: string): string | undefined => tag.startsWith("v") && SEMVER.test(tag.slice(1)) ? tag.slice(1) : undefined;
|
|
17
|
+
|
|
18
|
+
// Hook values are opaque Git data, not shell source. Unicode and shell-looking
|
|
19
|
+
// characters are valid in refs; only Git's forbidden ref syntax is rejected.
|
|
20
|
+
const hookText = (value: string) => value.length > 0 && value.length <= 4096 && !/[\p{Cc}]/u.test(value);
|
|
21
|
+
const hookRef = (value: string) => hookText(value) && !/[\0-\x20\x7f]/.test(value) && !"~^:?*[\\".split("").some(char => value.includes(char)) && !value.includes("..") && !value.includes("@{") && !value.includes("//") && !value.endsWith("/") && !value.endsWith(".") && !value.split("/").some(part => !part || part.startsWith(".") || part.endsWith(".lock"));
|
|
22
|
+
|
|
23
|
+
export function parseReleaseGuardArgv(argv: readonly string[]): ReleaseGuardParse {
|
|
24
|
+
if (!Array.isArray(argv) || !argv.length || argv.length > 128 || !argv.every(word => typeof word === "string" && word.length <= 4096 && !/[\0-\x1f\x7f]/.test(word)) || Buffer.byteLength(argv.join("\0")) > 16 * 1024) return refuse();
|
|
25
|
+
const [program, ...args] = argv;
|
|
26
|
+
if (program === "git" && args[0] === "push") {
|
|
27
|
+
if (args.length !== 3 || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(args[1] ?? "")) return refuse();
|
|
28
|
+
const spec = args[2]!, parts = spec.split(":");
|
|
29
|
+
if (parts.length > 2 || spec.startsWith("+")) return refuse();
|
|
30
|
+
const destination = parts.length === 2 ? parts[1]! : parts[0]!;
|
|
31
|
+
const tag = destination.startsWith("refs/tags/") ? destination.slice(10) : destination;
|
|
32
|
+
const version = versionOf(tag), sourceRef = parts[0]!;
|
|
33
|
+
if (!version || !ref(sourceRef) || parts.length === 2 && !destination.startsWith("refs/tags/")) return refuse();
|
|
34
|
+
return { ok: true, request: { kind: "tag-push", version, tag, sourceRef, remote: args[1]! } };
|
|
35
|
+
}
|
|
36
|
+
if (program === "gh" && args[0] === "release" && args[1] === "create") {
|
|
37
|
+
const tag = args[2] ?? "", version = versionOf(tag);
|
|
38
|
+
if (!version) return refuse();
|
|
39
|
+
let target: string | undefined;
|
|
40
|
+
const valued = new Set(["--title", "-t", "--notes", "-n", "--notes-file", "-F", "--notes-start-tag", "--target"]);
|
|
41
|
+
const flags = new Set(["--verify-tag", "--generate-notes", "--draft", "-d", "--prerelease", "-p", "--fail-on-no-commits"]);
|
|
42
|
+
for (let i = 3; i < args.length; i++) {
|
|
43
|
+
const flag = args[i]!;
|
|
44
|
+
if (flags.has(flag)) continue;
|
|
45
|
+
if (!valued.has(flag) || args[i + 1] === undefined) return refuse();
|
|
46
|
+
const value = args[++i]!;
|
|
47
|
+
if (flag === "--target") { if (target !== undefined || !ref(value)) return refuse(); target = value; }
|
|
48
|
+
}
|
|
49
|
+
return { ok: true, request: { kind: "gh-release", version, tag, ...(target === undefined ? {} : { target }) } };
|
|
50
|
+
}
|
|
51
|
+
if (program === "npm" && args[0] === "publish") {
|
|
52
|
+
const flags = new Set(["--provenance", "--ignore-scripts", "--dry-run"]);
|
|
53
|
+
for (let i = 1; i < args.length; i++) {
|
|
54
|
+
if (flags.has(args[i]!)) continue;
|
|
55
|
+
if (args[i] === "--access" && ["public", "restricted"].includes(args[i + 1] ?? "")) { i++; continue; }
|
|
56
|
+
if (args[i] === "--tag" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(args[i + 1] ?? "")) { i++; continue; }
|
|
57
|
+
return refuse();
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, request: { kind: "npm-publish" } };
|
|
60
|
+
}
|
|
61
|
+
return refuse();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Validate every hook row before selecting version-tag updates. Preserve the exact original input. */
|
|
65
|
+
export function parseReleasePushUpdates(remoteName: string, remoteUrl: string, stdin: string): ReleasePushParse {
|
|
66
|
+
if (typeof remoteName !== "string" || typeof remoteUrl !== "string" || typeof stdin !== "string" || !hookText(remoteName) || !hookText(remoteUrl) || Buffer.byteLength(stdin) > 128 * 1024) return refuse();
|
|
67
|
+
const lines = stdin.endsWith("\n") ? stdin.slice(0, -1).split("\n") : stdin.split("\n");
|
|
68
|
+
if (stdin === "") lines.length = 0;
|
|
69
|
+
if (lines.length > 128) return refuse();
|
|
70
|
+
const updates: ReleasePushUpdate[] = [];
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const fields = line.split(" ");
|
|
73
|
+
if (fields.length !== 4) return refuse();
|
|
74
|
+
const [localRef, localOid, remoteRef, remoteOid] = fields as [string, string, string, string];
|
|
75
|
+
if ((!hookRef(localRef) && localRef !== "(delete)") || !hookRef(remoteRef) || !remoteRef.startsWith("refs/") || !oid(localOid) || !oid(remoteOid)) return refuse();
|
|
76
|
+
if (!remoteRef.startsWith("refs/tags/v")) continue;
|
|
77
|
+
const version = versionOf(remoteRef.slice(10));
|
|
78
|
+
if (!version || zero(localOid) || localRef === "(delete)") return refuse();
|
|
79
|
+
updates.push({ localRef, localOid, remoteRef, remoteOid, version });
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, remoteName, remoteUrl, updates, stdin };
|
|
82
|
+
}
|