@indigoai-us/hq-cli 5.306.0 → 5.307.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 +9 -0
- package/assets/scaffold/core/scripts/audit-log.sh +384 -0
- package/assets/scaffold/core/scripts/git-pack-extension-check.sh +164 -0
- package/assets/scaffold/core/scripts/hq-pin.sh +118 -0
- package/assets/scaffold/core/scripts/lib/portable.sh +211 -0
- package/assets/scaffold/core/scripts/refresh-vault-access.sh +188 -0
- package/dist/command-catalog.generated.d.ts +52 -0
- package/dist/command-catalog.generated.js +64 -0
- package/dist/commands/core-audit-log-native.d.ts +9 -0
- package/dist/commands/core-audit-log-native.js +257 -0
- package/dist/commands/core-git-pack-extension-check-native.d.ts +22 -0
- package/dist/commands/core-git-pack-extension-check-native.js +190 -0
- package/dist/commands/core-native-utilities-flag.d.ts +4 -0
- package/dist/commands/core-native-utilities-flag.js +7 -0
- package/dist/commands/core-native-utilities.d.ts +28 -0
- package/dist/commands/core-native-utilities.js +52 -0
- package/dist/commands/core-pin-native.d.ts +14 -0
- package/dist/commands/core-pin-native.js +236 -0
- package/dist/commands/core-refresh-vault-access-native.d.ts +20 -0
- package/dist/commands/core-refresh-vault-access-native.js +229 -0
- package/dist/commands/core.js +15 -0
- package/dist/commands/dm.js +6 -1
- package/dist/commands/lanes.js +43 -1
- package/dist/lib/agent-kit/fallback.d.ts +0 -7
- package/dist/lib/agent-kit/fallback.js +4 -4
- package/dist/lib/agent-kit/inbox-state.d.ts +0 -2
- package/dist/lib/agent-kit/inbox-state.js +2 -2
- package/dist/lib/agent-kit/kit-config.d.ts +0 -5
- package/dist/lib/agent-kit/kit-config.js +5 -5
- package/dist/lib/agent-kit/log.d.ts +0 -1
- package/dist/lib/agent-kit/log.js +1 -1
- package/dist/lib/agent-kit/mcp/jsonrpc.d.ts +3 -12
- package/dist/lib/agent-kit/mcp/jsonrpc.js +4 -4
- package/dist/lib/agent-kit/mcp/tools.d.ts +0 -2
- package/dist/lib/agent-kit/mcp/tools.js +2 -2
- package/dist/lib/agent-kit/services.d.ts +0 -1
- package/dist/lib/agent-kit/services.js +1 -1
- package/dist/lib/checkpoint-ontology/apply.d.ts +0 -49
- package/dist/lib/checkpoint-ontology/apply.js +5 -5
- package/dist/lib/checkpoint-ontology/identity.d.ts +2 -1
- package/dist/lib/checkpoint-ontology/identity.js +1 -1
- package/dist/lib/checkpoint-ontology/multi-writer.d.ts +4 -9
- package/dist/lib/checkpoint-ontology/multi-writer.js +6 -6
- package/dist/lib/checkpoint-ontology/plan.d.ts +4 -17
- package/dist/lib/checkpoint-ontology/plan.js +5 -5
- package/dist/lib/checkpoint-ontology/queued-touches.d.ts +0 -3
- package/dist/lib/checkpoint-ontology/queued-touches.js +3 -3
- package/dist/lib/db/control-plane.js +6 -0
- package/dist/lib/deploy-apps.js +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/** Native per-session pin implementation matching core/scripts/hq-pin.sh. */
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
6
|
+
import { bundledShellUnboundValue, registerNativeUtility, utilityError } from "./core-native-utilities.js";
|
|
7
|
+
export const PIN_ASSET = "core/scripts/hq-pin.sh";
|
|
8
|
+
export const PIN_HELP = `# hq-pin.sh — anchor the current session to one goal (the "pin").
|
|
9
|
+
#
|
|
10
|
+
# A pin is the message, goal, or target the session keeps working toward
|
|
11
|
+
# across loops, wakeups, compactions, and hand-offs. It is stored per session
|
|
12
|
+
# and re-read at every checkpoint so the session cannot drift.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# core/scripts/hq-pin.sh set "<goal>" [--done "<criterion>"]... [--owner <name>] [--channel <dm-channel>]
|
|
16
|
+
# core/scripts/hq-pin.sh show # print the pin (goal, done criteria, owner, channel, set-at)
|
|
17
|
+
# core/scripts/hq-pin.sh check # print a one-line reminder for loop ticks; exit 3 if no pin
|
|
18
|
+
# core/scripts/hq-pin.sh note "<progress>" # append a dated progress line under the pin
|
|
19
|
+
# core/scripts/hq-pin.sh clear # remove the pin (the goal is met or withdrawn)
|
|
20
|
+
#
|
|
21
|
+
# Storage: workspace/sessions/<sid>/pin.md plus \`pin: <goal>\` in the session's
|
|
22
|
+
`;
|
|
23
|
+
const SESSION_ENV_KEYS = ["HQ_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID", "CODEX_SESSION_ID", "CODEX_THREAD_ID", "GROK_SESSION_ID"];
|
|
24
|
+
const stamp = (date) => date.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
25
|
+
function normalizeId(value) { return value.replace(/[\t\n\v\f\r ]/g, ""); }
|
|
26
|
+
function validId(value) { return value !== "" && value !== "." && value !== ".." && /^[A-Za-z0-9._-]+$/.test(value); }
|
|
27
|
+
export function resolvePinSessionId(root, env) {
|
|
28
|
+
for (const key of SESSION_ENV_KEYS) {
|
|
29
|
+
const value = normalizeId(env[key] ?? "");
|
|
30
|
+
if (validId(value))
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const value = normalizeId(fs.readFileSync(path.join(root, "workspace/sessions/.current"), "utf8"));
|
|
35
|
+
return validId(value) ? value : "";
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function pinPath(deps, sid) { return path.join(deps.root, "workspace/sessions", sid, "pin.md"); }
|
|
42
|
+
function fail(message) { return utilityError(`hq-pin: ${message}`, 2); }
|
|
43
|
+
function sessionMetaPath(deps, sid) { return path.join(deps.root, "workspace/sessions", sid, "meta.yaml"); }
|
|
44
|
+
function writeSessionPin(deps, sid, goal) {
|
|
45
|
+
const meta = sessionMetaPath(deps, sid);
|
|
46
|
+
fs.mkdirSync(path.dirname(meta), { recursive: true });
|
|
47
|
+
let contents;
|
|
48
|
+
try {
|
|
49
|
+
contents = fs.readFileSync(meta, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code !== "ENOENT")
|
|
53
|
+
throw error;
|
|
54
|
+
contents = `session_id: ${sid}\nstarted_at: "${stamp(deps.now())}"\nsenior: user\n`;
|
|
55
|
+
}
|
|
56
|
+
const lines = contents ? contents.replace(/\n$/, "").split("\n") : [];
|
|
57
|
+
let found = false;
|
|
58
|
+
const next = lines.map((line) => {
|
|
59
|
+
if (line.trimStart().split(/[\t ]/)[0] === "pin:") {
|
|
60
|
+
found = true;
|
|
61
|
+
return `pin: ${goal}`;
|
|
62
|
+
}
|
|
63
|
+
return line;
|
|
64
|
+
});
|
|
65
|
+
if (!found)
|
|
66
|
+
next.push(`pin: ${goal}`);
|
|
67
|
+
const text = `${next.join("\n")}\n`;
|
|
68
|
+
const temp = path.join(path.dirname(meta), `.meta.yaml.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
69
|
+
fs.writeFileSync(temp, text, { mode: 0o600 });
|
|
70
|
+
fs.renameSync(temp, meta);
|
|
71
|
+
}
|
|
72
|
+
function createPinDirectory(deps) {
|
|
73
|
+
const sid = resolvePinSessionId(deps.root, deps.env);
|
|
74
|
+
if (!sid)
|
|
75
|
+
return { error: fail("no current session (hq-session.sh current is empty)") };
|
|
76
|
+
const file = pinPath(deps, sid);
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
return { sid, file };
|
|
79
|
+
}
|
|
80
|
+
function setPin(args, deps) {
|
|
81
|
+
const goal = args[0] ?? "";
|
|
82
|
+
if (!goal)
|
|
83
|
+
return fail("set needs the goal text");
|
|
84
|
+
let owner = "";
|
|
85
|
+
let channel = "";
|
|
86
|
+
const done = [];
|
|
87
|
+
for (let i = 1; i < args.length;) {
|
|
88
|
+
const arg = args[i];
|
|
89
|
+
if (arg === "--done") {
|
|
90
|
+
if (i + 1 >= args.length)
|
|
91
|
+
return bundledShellUnboundValue(PIN_ASSET, 36);
|
|
92
|
+
done.push(args[i + 1]);
|
|
93
|
+
i += 2;
|
|
94
|
+
}
|
|
95
|
+
else if (arg === "--owner") {
|
|
96
|
+
if (i + 1 >= args.length)
|
|
97
|
+
return bundledShellUnboundValue(PIN_ASSET, 37);
|
|
98
|
+
owner = args[i + 1];
|
|
99
|
+
i += 2;
|
|
100
|
+
}
|
|
101
|
+
else if (arg === "--channel") {
|
|
102
|
+
if (i + 1 >= args.length)
|
|
103
|
+
return bundledShellUnboundValue(PIN_ASSET, 38);
|
|
104
|
+
channel = args[i + 1];
|
|
105
|
+
i += 2;
|
|
106
|
+
}
|
|
107
|
+
else
|
|
108
|
+
return fail(`set: unknown arg ${arg}`);
|
|
109
|
+
}
|
|
110
|
+
const current = createPinDirectory(deps);
|
|
111
|
+
if (current.error)
|
|
112
|
+
return current.error;
|
|
113
|
+
const lines = ["# Pin", "", `Goal: ${goal}`, "", `Set: ${stamp(deps.now())}`];
|
|
114
|
+
if (owner)
|
|
115
|
+
lines.push(`Owner: ${owner}`);
|
|
116
|
+
if (channel)
|
|
117
|
+
lines.push(`Channel: ${channel}`);
|
|
118
|
+
if (done.length > 0)
|
|
119
|
+
lines.push("", "## Done when", ...done.map((criterion) => `- [ ] ${criterion}`));
|
|
120
|
+
lines.push("", "## Progress");
|
|
121
|
+
fs.writeFileSync(current.file, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o666 });
|
|
122
|
+
writeSessionPin(deps, current.sid, goal);
|
|
123
|
+
return { code: 0, stdout: `pinned session ${current.sid}: ${goal}\n`, stderr: "" };
|
|
124
|
+
}
|
|
125
|
+
function showPin(deps) {
|
|
126
|
+
const current = createPinDirectory(deps);
|
|
127
|
+
if (current.error)
|
|
128
|
+
return current.error;
|
|
129
|
+
if (!fs.existsSync(current.file))
|
|
130
|
+
return { code: 3, stdout: `no pin for session ${current.sid}\n`, stderr: "" };
|
|
131
|
+
return { code: 0, stdout: fs.readFileSync(current.file, "utf8"), stderr: "" };
|
|
132
|
+
}
|
|
133
|
+
function checkPin(deps) {
|
|
134
|
+
const current = createPinDirectory(deps);
|
|
135
|
+
if (current.error)
|
|
136
|
+
return current.error;
|
|
137
|
+
if (!fs.existsSync(current.file))
|
|
138
|
+
return { code: 3, stdout: "no pin\n", stderr: "" };
|
|
139
|
+
const text = fs.readFileSync(current.file, "utf8");
|
|
140
|
+
const goal = text.split(/\r?\n/).find((line) => line.startsWith("Goal: "))?.slice(6) ?? "";
|
|
141
|
+
const open = (text.match(/^- \[ \]/gm) ?? []).length;
|
|
142
|
+
const closed = (text.match(/^- \[x\]/gm) ?? []).length;
|
|
143
|
+
return { code: 0, stdout: `PIN: ${goal} (done criteria: ${closed} met, ${open} open)\n`, stderr: "" };
|
|
144
|
+
}
|
|
145
|
+
function notePin(args, deps) {
|
|
146
|
+
const text = args[0] ?? "";
|
|
147
|
+
if (!text)
|
|
148
|
+
return fail("note needs text");
|
|
149
|
+
const current = createPinDirectory(deps);
|
|
150
|
+
if (current.error)
|
|
151
|
+
return current.error;
|
|
152
|
+
if (!fs.existsSync(current.file))
|
|
153
|
+
return fail("no pin to note against");
|
|
154
|
+
const time = deps.now().toISOString().slice(11, 16);
|
|
155
|
+
fs.appendFileSync(current.file, `- ${time}Z ${text}\n`);
|
|
156
|
+
return { code: 0, stdout: "noted\n", stderr: "" };
|
|
157
|
+
}
|
|
158
|
+
function donePin(args, deps) {
|
|
159
|
+
const needle = args[0] ?? "";
|
|
160
|
+
if (!needle)
|
|
161
|
+
return fail("done needs a criterion substring");
|
|
162
|
+
const current = createPinDirectory(deps);
|
|
163
|
+
if (current.error)
|
|
164
|
+
return current.error;
|
|
165
|
+
if (!fs.existsSync(current.file))
|
|
166
|
+
return fail("no pin");
|
|
167
|
+
const text = fs.readFileSync(current.file, "utf8");
|
|
168
|
+
const lines = text.split("\n");
|
|
169
|
+
// Bash read ignores a final line without a newline. The shell script then
|
|
170
|
+
// echoes every line it did read, adding a newline to each one.
|
|
171
|
+
lines.pop();
|
|
172
|
+
let hit = false;
|
|
173
|
+
const next = lines.map((line) => {
|
|
174
|
+
if (line.startsWith("- [ ] ") && line.includes(needle)) {
|
|
175
|
+
hit = true;
|
|
176
|
+
// Preserve the bundled shell's parameter-expansion result: it keeps the
|
|
177
|
+
// matched source line after adding the checked marker.
|
|
178
|
+
return `- [x] ${line}`;
|
|
179
|
+
}
|
|
180
|
+
return line;
|
|
181
|
+
});
|
|
182
|
+
const output = next.length > 0 ? `${next.join("\n")}\n` : "";
|
|
183
|
+
const tempDir = fs.mkdtempSync(path.join(deps.tmpDir ?? os.tmpdir(), "hq-pin-"));
|
|
184
|
+
const tempFile = path.join(tempDir, "pin");
|
|
185
|
+
try {
|
|
186
|
+
fs.writeFileSync(tempFile, output, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
187
|
+
try {
|
|
188
|
+
fs.renameSync(tempFile, current.file);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (error.code !== "EXDEV")
|
|
192
|
+
throw error;
|
|
193
|
+
fs.copyFileSync(tempFile, current.file);
|
|
194
|
+
fs.chmodSync(current.file, 0o600);
|
|
195
|
+
fs.unlinkSync(tempFile);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
return hit
|
|
202
|
+
? { code: 0, stdout: `marked done: ${needle}\n`, stderr: "" }
|
|
203
|
+
: fail(`no open criterion matches: ${needle}`);
|
|
204
|
+
}
|
|
205
|
+
function clearPin(deps) {
|
|
206
|
+
const current = createPinDirectory(deps);
|
|
207
|
+
if (current.error)
|
|
208
|
+
return current.error;
|
|
209
|
+
fs.rmSync(current.file, { force: true });
|
|
210
|
+
writeSessionPin(deps, current.sid, "");
|
|
211
|
+
return { code: 0, stdout: "pin cleared\n", stderr: "" };
|
|
212
|
+
}
|
|
213
|
+
export function runPin(args, deps) {
|
|
214
|
+
const [command = "", ...rest] = args;
|
|
215
|
+
if (command === "set")
|
|
216
|
+
return setPin(rest, deps);
|
|
217
|
+
if (command === "show")
|
|
218
|
+
return showPin(deps);
|
|
219
|
+
if (command === "check")
|
|
220
|
+
return checkPin(deps);
|
|
221
|
+
if (command === "note")
|
|
222
|
+
return notePin(rest, deps);
|
|
223
|
+
if (command === "done")
|
|
224
|
+
return donePin(rest, deps);
|
|
225
|
+
if (command === "clear")
|
|
226
|
+
return clearPin(deps);
|
|
227
|
+
return { code: 2, stdout: PIN_HELP, stderr: "" };
|
|
228
|
+
}
|
|
229
|
+
export function registerCorePinNative(core) {
|
|
230
|
+
registerNativeUtility(core, {
|
|
231
|
+
name: "pin", asset: PIN_ASSET, root: "live", summary: "Pin the current session to one goal",
|
|
232
|
+
resolveRoot: (_cwd, hqRoot) => resolveLiveRoot({ hqRoot }),
|
|
233
|
+
run: (args, context) => runPin(args, context),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=core-pin-native.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { type UtilityOutput } from "./core-native-utilities.js";
|
|
3
|
+
export declare const REFRESH_VAULT_ACCESS_ASSET = "core/scripts/refresh-vault-access.sh";
|
|
4
|
+
export declare const REFRESH_VAULT_ACCESS_HELP: string;
|
|
5
|
+
export type HqResult = {
|
|
6
|
+
code: number;
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
missing?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type RefreshVaultAccessDependencies = {
|
|
12
|
+
cwd: string;
|
|
13
|
+
env: NodeJS.ProcessEnv;
|
|
14
|
+
now: () => Date;
|
|
15
|
+
hqAvailable: boolean;
|
|
16
|
+
runHq?: (args: string[], cwd: string) => HqResult;
|
|
17
|
+
};
|
|
18
|
+
export declare function runRefreshVaultAccess(args: string[], deps: RefreshVaultAccessDependencies): UtilityOutput;
|
|
19
|
+
export declare function registerCoreRefreshVaultAccessNative(core: Command): void;
|
|
20
|
+
//# sourceMappingURL=core-refresh-vault-access-native.d.ts.map
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/** Native local manifest refresh for core/scripts/refresh-vault-access.sh. */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { serializeJqJson } from "../lib/core-utils/jq-json-serializer.js";
|
|
6
|
+
import { registerNativeUtility } from "./core-native-utilities.js";
|
|
7
|
+
export const REFRESH_VAULT_ACCESS_ASSET = "core/scripts/refresh-vault-access.sh";
|
|
8
|
+
export const REFRESH_VAULT_ACCESS_HELP = `# hq-core: public
|
|
9
|
+
# refresh-vault-access.sh — build/refresh the local vault-access manifest that
|
|
10
|
+
# enforce-vault-write-access.sh (PreToolUse hook) reads.
|
|
11
|
+
#
|
|
12
|
+
# Output: <hqRoot>/.hq/vault-access.json
|
|
13
|
+
#
|
|
14
|
+
# {
|
|
15
|
+
# "version": 1,
|
|
16
|
+
# "generatedAt": "2026-01-01T00:00:00Z",
|
|
17
|
+
# "companies": {
|
|
18
|
+
# "<slug>": {
|
|
19
|
+
# "role": "owner|admin|member|guest|unknown",
|
|
20
|
+
# "grants": [ { "path": "reports/*", "permission": "read|write" } ]
|
|
21
|
+
# }
|
|
22
|
+
# }
|
|
23
|
+
# }
|
|
24
|
+
#
|
|
25
|
+
# Sources (best-effort, all via the hq CLI):
|
|
26
|
+
# - grants: \`hq files shared-with-me\` (explicit per-prefix grants)
|
|
27
|
+
# - role: \`hq members --company <slug> list\` matched against the caller's
|
|
28
|
+
# email (from \`hq whoami\`, override with HQ_VAULT_ACCESS_EMAIL)
|
|
29
|
+
#
|
|
30
|
+
# Fail-open by design: anything this script cannot determine is recorded as
|
|
31
|
+
# role "unknown" (the hook does not enforce unknown roles), and a company it
|
|
32
|
+
# cannot query at all is left out of the manifest entirely (the hook allows
|
|
33
|
+
# companies absent from the manifest). The server-side STS/ACL layer remains
|
|
34
|
+
# the authoritative security boundary — this manifest only powers the local
|
|
35
|
+
# early-warning block for read-only shares.
|
|
36
|
+
#
|
|
37
|
+
# Usage:
|
|
38
|
+
# bash core/scripts/refresh-vault-access.sh [--company <slug>]... [--root <hqRoot>]
|
|
39
|
+
#
|
|
40
|
+
`.replace(/^# ?/gm, "");
|
|
41
|
+
function defaultHq(args, cwd, env) {
|
|
42
|
+
const result = spawnSync("hq", args, { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
43
|
+
if (result.error)
|
|
44
|
+
return { code: 127, stdout: "", stderr: result.error.message, missing: result.error.code === "ENOENT" };
|
|
45
|
+
return { code: result.status ?? 128, stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? "") };
|
|
46
|
+
}
|
|
47
|
+
function executableOnPath(name, value) {
|
|
48
|
+
if (!value)
|
|
49
|
+
return false;
|
|
50
|
+
for (const directory of value.split(path.delimiter)) {
|
|
51
|
+
if (!directory)
|
|
52
|
+
continue;
|
|
53
|
+
const candidate = path.join(directory, name);
|
|
54
|
+
try {
|
|
55
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch { /* keep checking the PATH */ }
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
function isHqRoot(root) {
|
|
63
|
+
const candidate = root.endsWith(path.sep) ? root.slice(0, -1) : root;
|
|
64
|
+
if (!candidate)
|
|
65
|
+
return false;
|
|
66
|
+
try {
|
|
67
|
+
return fs.statSync(path.join(candidate, "core")).isDirectory() && fs.statSync(path.join(candidate, ".claude")).isDirectory();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function parseOptions(args) {
|
|
74
|
+
let root = "";
|
|
75
|
+
const slugs = [];
|
|
76
|
+
for (let i = 0; i < args.length;) {
|
|
77
|
+
const arg = args[i];
|
|
78
|
+
if (arg === "--root") {
|
|
79
|
+
if (i + 1 >= args.length)
|
|
80
|
+
return { root, slugs, help: false, error: "--root requires a value" };
|
|
81
|
+
root = args[i + 1];
|
|
82
|
+
i += 2;
|
|
83
|
+
}
|
|
84
|
+
else if (arg === "--company") {
|
|
85
|
+
if (i + 1 >= args.length)
|
|
86
|
+
return { root, slugs, help: false, error: "--company requires a value" };
|
|
87
|
+
slugs.push(args[i + 1]);
|
|
88
|
+
i += 2;
|
|
89
|
+
}
|
|
90
|
+
else if (arg === "-h" || arg === "--help")
|
|
91
|
+
return { root, slugs, help: true };
|
|
92
|
+
else
|
|
93
|
+
return { root, slugs, help: false, error: `unknown argument: ${arg}` };
|
|
94
|
+
}
|
|
95
|
+
return { root, slugs, help: false };
|
|
96
|
+
}
|
|
97
|
+
function resolveRoot(parsedRoot, deps) {
|
|
98
|
+
if (parsedRoot)
|
|
99
|
+
return parsedRoot;
|
|
100
|
+
if (isHqRoot(deps.env.CLAUDE_PROJECT_DIR ?? ""))
|
|
101
|
+
return deps.env.CLAUDE_PROJECT_DIR;
|
|
102
|
+
let current = path.resolve(deps.cwd);
|
|
103
|
+
while (current !== path.parse(current).root) {
|
|
104
|
+
if (isHqRoot(current))
|
|
105
|
+
return current;
|
|
106
|
+
current = path.dirname(current);
|
|
107
|
+
}
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
// eslint-disable-next-line no-control-regex -- strip ANSI SGR sequences printed by hq.
|
|
111
|
+
function cleanAnsi(value) { return value.replace(/\u001b\[[0-9;]*m/g, ""); }
|
|
112
|
+
function log(message) { return `refresh-vault-access: ${message}\n`; }
|
|
113
|
+
function getEmail(output) {
|
|
114
|
+
const matches = cleanAnsi(output).match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g) ?? [];
|
|
115
|
+
return matches.find((email) => !email.includes("@outposts.")) ?? "";
|
|
116
|
+
}
|
|
117
|
+
function parseGrantTable(output) {
|
|
118
|
+
const grants = new Map();
|
|
119
|
+
for (let line of cleanAnsi(output).split(/\r?\n/)) {
|
|
120
|
+
line = line.replace(" (private folder)", "");
|
|
121
|
+
if (/^COMPANY\s/.test(line) || /^─/.test(line) || /^\s*$/.test(line) || /^Nothing is explicitly shared/.test(line))
|
|
122
|
+
continue;
|
|
123
|
+
const fields = line.trim().split(/\s+/);
|
|
124
|
+
if (fields.length < 3)
|
|
125
|
+
continue;
|
|
126
|
+
const permission = ["read", "write", "admin"].includes(fields[2]) ? fields[2]
|
|
127
|
+
: fields.length >= 4 && ["read", "write", "admin"].includes(fields[3]) ? fields[3] : "";
|
|
128
|
+
if (!permission)
|
|
129
|
+
continue;
|
|
130
|
+
const rows = grants.get(fields[0]) ?? [];
|
|
131
|
+
rows.push({ path: fields[1], permission });
|
|
132
|
+
grants.set(fields[0], rows);
|
|
133
|
+
}
|
|
134
|
+
return grants;
|
|
135
|
+
}
|
|
136
|
+
function roleFromTable(output, email) {
|
|
137
|
+
if (!email)
|
|
138
|
+
return "unknown";
|
|
139
|
+
for (const line of cleanAnsi(output).split(/\r?\n/)) {
|
|
140
|
+
const [candidate, role] = line.trim().split(/\s+/);
|
|
141
|
+
if (candidate === email && ["owner", "admin", "member", "guest"].includes(role ?? ""))
|
|
142
|
+
return role;
|
|
143
|
+
if (candidate === email)
|
|
144
|
+
return "unknown";
|
|
145
|
+
}
|
|
146
|
+
return "unknown";
|
|
147
|
+
}
|
|
148
|
+
function resolveEmail(deps, runHq) {
|
|
149
|
+
const override = deps.env.HQ_VAULT_ACCESS_EMAIL ?? "";
|
|
150
|
+
if (override)
|
|
151
|
+
return { email: override, stderr: "" };
|
|
152
|
+
const result = runHq(["whoami"], deps.cwd);
|
|
153
|
+
const email = getEmail(result.stdout);
|
|
154
|
+
return { email, stderr: email ? "" : log("could not determine caller email — roles will be 'unknown' (hook will not enforce)") };
|
|
155
|
+
}
|
|
156
|
+
export function runRefreshVaultAccess(args, deps) {
|
|
157
|
+
const parsed = parseOptions(args);
|
|
158
|
+
if (parsed.help)
|
|
159
|
+
return { code: 0, stdout: REFRESH_VAULT_ACCESS_HELP, stderr: "" };
|
|
160
|
+
if (parsed.error)
|
|
161
|
+
return { code: 1, stdout: "", stderr: log(parsed.error) };
|
|
162
|
+
const root = resolveRoot(parsed.root, deps);
|
|
163
|
+
if (!isHqRoot(root))
|
|
164
|
+
return { code: 1, stdout: "", stderr: log("could not resolve the HQ root (pass --root <hqRoot>)") };
|
|
165
|
+
if (!deps.hqAvailable)
|
|
166
|
+
return { code: 0, stdout: "", stderr: log("hq CLI not found — nothing refreshed (the write-access hook stays fail-open without a manifest)") };
|
|
167
|
+
const runHq = deps.runHq ?? ((hqArgs, cwd) => defaultHq(hqArgs, cwd, deps.env));
|
|
168
|
+
const identity = resolveEmail(deps, runHq);
|
|
169
|
+
let stderr = identity.stderr;
|
|
170
|
+
const explicit = parsed.slugs;
|
|
171
|
+
const allGrants = new Map();
|
|
172
|
+
if (explicit.length > 0) {
|
|
173
|
+
for (const slug of explicit) {
|
|
174
|
+
const result = runHq(["files", "shared-with-me", "--company", slug], deps.cwd);
|
|
175
|
+
if (result.code !== 0) {
|
|
176
|
+
stderr += log(`skipping ${slug} — shared-with-me query failed`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const rows = parseGrantTable(result.stdout);
|
|
180
|
+
for (const [company, grants] of rows)
|
|
181
|
+
allGrants.set(company, [...(allGrants.get(company) ?? []), ...grants]);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const result = runHq(["files", "shared-with-me"], deps.cwd);
|
|
186
|
+
if (result.code !== 0)
|
|
187
|
+
return { code: 0, stdout: "", stderr: `${stderr}${log("shared-with-me roll-up failed — nothing refreshed")}` };
|
|
188
|
+
for (const [company, grants] of parseGrantTable(result.stdout))
|
|
189
|
+
allGrants.set(company, grants);
|
|
190
|
+
}
|
|
191
|
+
const companies = new Set(allGrants.keys());
|
|
192
|
+
for (const slug of explicit)
|
|
193
|
+
companies.add(slug);
|
|
194
|
+
const sortedSlugs = [...companies].sort();
|
|
195
|
+
if (sortedSlugs.length === 0)
|
|
196
|
+
stderr += log("no explicit grants found — writing an empty manifest (hook stays fail-open everywhere)");
|
|
197
|
+
const companyData = {};
|
|
198
|
+
for (const slug of sortedSlugs) {
|
|
199
|
+
let role = "unknown";
|
|
200
|
+
if (identity.email) {
|
|
201
|
+
const result = runHq(["members", "--company", slug, "list"], deps.cwd);
|
|
202
|
+
role = result.code === 0 ? roleFromTable(result.stdout, identity.email) : "unknown";
|
|
203
|
+
}
|
|
204
|
+
const grants = (allGrants.get(slug) ?? []).map((grant) => ({ path: grant.path, permission: grant.permission }));
|
|
205
|
+
companyData[slug] = { role, grants };
|
|
206
|
+
}
|
|
207
|
+
const manifest = { version: 1, generatedAt: deps.now().toISOString().replace(/\.\d{3}Z$/, "Z"), companies: companyData };
|
|
208
|
+
const directory = path.join(root, ".hq");
|
|
209
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
210
|
+
const target = path.join(directory, "vault-access.json");
|
|
211
|
+
const temp = `${target}.${process.pid}.${Math.random().toString(16).slice(2)}`;
|
|
212
|
+
fs.writeFileSync(temp, serializeJqJson(manifest, "pretty"), { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
213
|
+
fs.chmodSync(temp, 0o600);
|
|
214
|
+
fs.renameSync(temp, target);
|
|
215
|
+
stderr += log(`wrote .hq/vault-access.json (${sortedSlugs.length} companies, identity: ${identity.email || "unknown"})`);
|
|
216
|
+
return { code: 0, stdout: "", stderr };
|
|
217
|
+
}
|
|
218
|
+
export function registerCoreRefreshVaultAccessNative(core) {
|
|
219
|
+
registerNativeUtility(core, {
|
|
220
|
+
name: "refresh-vault-access", asset: REFRESH_VAULT_ACCESS_ASSET, root: "cwd", summary: "Refresh the local vault access manifest",
|
|
221
|
+
resolveRoot: (cwd) => cwd,
|
|
222
|
+
run: (args, context) => runRefreshVaultAccess(args, {
|
|
223
|
+
cwd: context.cwd, env: context.env, now: context.now,
|
|
224
|
+
hqAvailable: executableOnPath("hq", context.env.PATH),
|
|
225
|
+
runHq: (args, cwd) => defaultHq(args, cwd, context.env),
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
//# sourceMappingURL=core-refresh-vault-access-native.js.map
|
package/dist/commands/core.js
CHANGED
|
@@ -38,6 +38,10 @@ import { registerCoreKnowledgePrefsCommand } from "./core-knowledge-prefs.js";
|
|
|
38
38
|
import { registerCorePolicyCommand } from "./core-policy.js";
|
|
39
39
|
import { registerCoreJobsNativeCommand } from "./core-job-native.js";
|
|
40
40
|
import { registerCoreCheckpointGateSettingCommand } from "./core-checkpoint-gate-setting.js";
|
|
41
|
+
import { registerCoreAuditLogNative } from "./core-audit-log-native.js";
|
|
42
|
+
import { registerCoreGitPackExtensionCheckNative } from "./core-git-pack-extension-check-native.js";
|
|
43
|
+
import { registerCorePinNative } from "./core-pin-native.js";
|
|
44
|
+
import { registerCoreRefreshVaultAccessNative } from "./core-refresh-vault-access-native.js";
|
|
41
45
|
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
42
46
|
import { interpreterFor, runBundledScript } from "../utils/run-bundled-script.js";
|
|
43
47
|
import { CompanyIndexUserError, renderIndexTarget } from "../lib/index-render/index.js";
|
|
@@ -271,6 +275,13 @@ export const SCAFFOLD_ONLY_ASSETS = [
|
|
|
271
275
|
{ asset: "core/scripts/handoff-post.sh", root: "live", summary: "Rollback shell for native handoff post" },
|
|
272
276
|
{ asset: "core/scripts/handoff-open-steps.sh", root: "live", summary: "Rollback shell for native handoff open-steps" },
|
|
273
277
|
{ asset: "core/scripts/qmd-reindex-bg.sh", root: "live", summary: "Rollback handoff reindex forwarder" },
|
|
278
|
+
// Wave 5 native utility rollback companions, sourced verbatim from staging
|
|
279
|
+
// because these assets were not present in the hq-cli bundle at launch.
|
|
280
|
+
{ asset: "core/scripts/audit-log.sh", root: "live", summary: "Rollback shell for native audit-log" },
|
|
281
|
+
{ asset: "core/scripts/git-pack-extension-check.sh", root: "cwd", summary: "Rollback shell for native git-pack-extension-check" },
|
|
282
|
+
{ asset: "core/scripts/hq-pin.sh", root: "live", summary: "Rollback shell for native pin" },
|
|
283
|
+
{ asset: "core/scripts/refresh-vault-access.sh", root: "cwd", summary: "Rollback shell for native refresh-vault-access" },
|
|
284
|
+
{ asset: "core/scripts/lib/portable.sh", root: "live", summary: "Shared portable helpers sourced by the audit-log rollback shell" },
|
|
274
285
|
...NATIVE_UTILITY_COMMANDS.map(({ asset, root, summary }) => ({
|
|
275
286
|
asset,
|
|
276
287
|
root,
|
|
@@ -354,6 +365,10 @@ export function registerCoreCommands(program) {
|
|
|
354
365
|
registerCoreKnowledgePrefsCommand(core);
|
|
355
366
|
registerCorePolicyCommand(core);
|
|
356
367
|
registerCoreJobsNativeCommand(core);
|
|
368
|
+
registerCoreAuditLogNative(core);
|
|
369
|
+
registerCoreGitPackExtensionCheckNative(core);
|
|
370
|
+
registerCorePinNative(core);
|
|
371
|
+
registerCoreRefreshVaultAccessNative(core);
|
|
357
372
|
// Wave 2 handoff/worktree commands retain their byte-identical shell assets
|
|
358
373
|
// as a default-off rollback path for one release. Keep the gate helper small
|
|
359
374
|
// so sibling Wave 2 work can reuse it without editing this command table.
|
package/dist/commands/dm.js
CHANGED
|
@@ -407,7 +407,12 @@ async function runConnectionAction(action, identifier) {
|
|
|
407
407
|
const token = await ensureCognitoToken();
|
|
408
408
|
// Resolve the pairKey from the caller's pending requests when possible so
|
|
409
409
|
// accept/decline/block work against a friendly email or personUid.
|
|
410
|
-
const requests = await fetchRequests(token).catch(() =>
|
|
410
|
+
const requests = await fetchRequests(token).catch((err) => {
|
|
411
|
+
console.error("hq dm: pending connection request lookup failed", {
|
|
412
|
+
errorName: err instanceof Error ? err.name : typeof err,
|
|
413
|
+
});
|
|
414
|
+
return [];
|
|
415
|
+
});
|
|
411
416
|
const matched = matchRequest(requests, identifier);
|
|
412
417
|
const body = buildConnectionActionBody(identifier, matched);
|
|
413
418
|
const res = await vaultApiFetch({
|
package/dist/commands/lanes.js
CHANGED
|
@@ -184,8 +184,12 @@ async function defaultDispatchOutpostExec(input) {
|
|
|
184
184
|
throw new LanesError("outpost_dispatch_failed", `Outpost exec failed: ${dispatchErrorMessage(err)}`, { outpost_id: input.outpostId, message: dispatchErrorMessage(err) });
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
|
+
// Pickup payloads are small; 30 seconds leaves room for normal network variance.
|
|
188
|
+
const OUTPOST_PICKUP_PAYLOAD_TIMEOUT_MS = 30_000;
|
|
187
189
|
async function defaultFetchText(url) {
|
|
188
|
-
const res = await fetch(url
|
|
190
|
+
const res = await fetch(url, {
|
|
191
|
+
signal: AbortSignal.timeout(OUTPOST_PICKUP_PAYLOAD_TIMEOUT_MS),
|
|
192
|
+
});
|
|
189
193
|
if (!res.ok) {
|
|
190
194
|
throw new LanesError("outpost_payload_invalid", `Failed to download pickup payload: HTTP ${res.status}`, { status: res.status });
|
|
191
195
|
}
|
|
@@ -1006,6 +1010,8 @@ const OUTPOST_HIRE_SUMMARY_TIMEOUT_SECONDS = 20;
|
|
|
1006
1010
|
const OUTPOST_HIRE_SUMMARY_WAIT_MS = 15_000;
|
|
1007
1011
|
const OUTPOST_SINGLE_LANE_WAIT_ENV = "HQ_LANES_OUTPOST_SINGLE_LANE_WAIT_MS";
|
|
1008
1012
|
const OUTPOST_SINGLE_LANE_WAIT_MAX_MS = 600_000;
|
|
1013
|
+
const LEGACY_PICKUP_SETTLEMENT_MARGIN_MS = 60_000;
|
|
1014
|
+
const LEGACY_PICKUP_SETTLEMENT_MIN_AGE_MS = OUTPOST_SINGLE_LANE_WAIT_MAX_MS + LEGACY_PICKUP_SETTLEMENT_MARGIN_MS;
|
|
1009
1015
|
function outpostSingleLaneWaitMs(env) {
|
|
1010
1016
|
const raw = env[OUTPOST_SINGLE_LANE_WAIT_ENV]?.trim() || String(PICKUP_WAIT_MS);
|
|
1011
1017
|
const value = /^\d+$/.test(raw) ? Number(raw) : Number.NaN;
|
|
@@ -1363,6 +1369,25 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds, opt
|
|
|
1363
1369
|
!ownerIsHostedByOutpost ||
|
|
1364
1370
|
(owner.company?.uid ?? null) !== (mirror.company?.uid ?? null) ||
|
|
1365
1371
|
(owner.company?.slug ?? null) !== (mirror.company?.slug ?? null)) {
|
|
1372
|
+
if (matches.length === 0 && isLegacyFailedDispatchWithoutPickupEvidence(mirror, nowMs)) {
|
|
1373
|
+
const settled = retryUpdateLane(hqRoot, mirror.lane_id, "reconcile", (current) => {
|
|
1374
|
+
if (!isLegacyFailedDispatchWithoutPickupEvidence(current, nowMs) ||
|
|
1375
|
+
current.host.id !== outpostId) {
|
|
1376
|
+
return current;
|
|
1377
|
+
}
|
|
1378
|
+
return {
|
|
1379
|
+
...current,
|
|
1380
|
+
terminal_details: {
|
|
1381
|
+
...(current.terminal_details ?? {}),
|
|
1382
|
+
pickup_persisted: false,
|
|
1383
|
+
},
|
|
1384
|
+
};
|
|
1385
|
+
});
|
|
1386
|
+
if (isUndispatchedOutpostMirror(settled)) {
|
|
1387
|
+
result.updated.set(mirror.lane_id, settled);
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1366
1391
|
if (matches.length === 0)
|
|
1367
1392
|
result.missingOnOwner.set(mirror.lane_id, mirror);
|
|
1368
1393
|
const detail = matches.length === 0
|
|
@@ -2530,6 +2555,23 @@ export function isUndispatchedOutpostMirror(lane) {
|
|
|
2530
2555
|
lane.terminal_details?.pickup_persisted === false &&
|
|
2531
2556
|
lane.last_host_report_at == null;
|
|
2532
2557
|
}
|
|
2558
|
+
function isLegacyFailedDispatchWithoutPickupEvidence(lane, nowMs) {
|
|
2559
|
+
const terminalDetails = lane.terminal_details;
|
|
2560
|
+
const updatedAtMs = Date.parse(lane.updated_at);
|
|
2561
|
+
const completedFailure = (terminalDetails?.status === "Failed" ||
|
|
2562
|
+
terminalDetails?.status === "Cancelled" ||
|
|
2563
|
+
terminalDetails?.status === "TimedOut") &&
|
|
2564
|
+
typeof terminalDetails.exit_code === "number" &&
|
|
2565
|
+
Number.isSafeInteger(terminalDetails.exit_code);
|
|
2566
|
+
return lane.host.kind === "outpost" &&
|
|
2567
|
+
isTerminalLaneState(lane.state) &&
|
|
2568
|
+
lane.terminal_reason === DISPATCH_TERMINAL_REASON &&
|
|
2569
|
+
lane.last_host_report_at == null &&
|
|
2570
|
+
lane.terminal_details?.pickup_persisted === undefined &&
|
|
2571
|
+
completedFailure &&
|
|
2572
|
+
Number.isFinite(updatedAtMs) &&
|
|
2573
|
+
nowMs - updatedAtMs > LEGACY_PICKUP_SETTLEMENT_MIN_AGE_MS;
|
|
2574
|
+
}
|
|
2533
2575
|
function parsePickupTerminalReason(value) {
|
|
2534
2576
|
if (PICKUP_TERMINAL_REASONS.includes(value)) {
|
|
2535
2577
|
return value;
|
|
@@ -9,10 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { ServiceHostPaths, ServiceSetResult } from "../service-manager/index.js";
|
|
11
11
|
import type { AgentKitPaths } from "./paths.js";
|
|
12
|
-
export declare const KIT_PID_NAME = "kit.pid";
|
|
13
|
-
export declare const KIT_FALLBACK_LOG_NAME = "kit.log";
|
|
14
12
|
export declare function kitPidPath(paths: Pick<AgentKitPaths, "agentDir">): string;
|
|
15
|
-
export declare function kitFallbackLogPath(paths: Pick<AgentKitPaths, "logsDir">): string;
|
|
16
13
|
/** True when an activation error means the service manager itself is unusable. */
|
|
17
14
|
export declare function isServiceManagerUnavailable(error: string | undefined): boolean;
|
|
18
15
|
/**
|
|
@@ -46,10 +43,6 @@ export interface DetachedSpawn {
|
|
|
46
43
|
unref(): void;
|
|
47
44
|
};
|
|
48
45
|
}
|
|
49
|
-
export declare function fallbackCommand(host: ServiceHostPaths): {
|
|
50
|
-
cmd: string;
|
|
51
|
-
args: string[];
|
|
52
|
-
};
|
|
53
46
|
/** crontab line that restarts the fallback supervisor after a reboot. */
|
|
54
47
|
export declare function rebootCrontabLine(paths: Pick<AgentKitPaths, "agentDir" | "logsDir">, host: ServiceHostPaths): string;
|
|
55
48
|
export declare function startFallback(paths: AgentKitPaths, host: ServiceHostPaths, deps?: {
|
|
@@ -12,12 +12,12 @@ import * as fs from "node:fs";
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { kitServiceEnv } from "./services.js";
|
|
14
14
|
import { writePrivateFile } from "./private-file.js";
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const KIT_PID_NAME = "kit.pid";
|
|
16
|
+
const KIT_FALLBACK_LOG_NAME = "kit.log";
|
|
17
17
|
export function kitPidPath(paths) {
|
|
18
18
|
return path.join(paths.agentDir, KIT_PID_NAME);
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
function kitFallbackLogPath(paths) {
|
|
21
21
|
return path.join(paths.logsDir, KIT_FALLBACK_LOG_NAME);
|
|
22
22
|
}
|
|
23
23
|
const UNAVAILABLE_PATTERNS = [
|
|
@@ -93,7 +93,7 @@ export function stopFallback(paths, kill = defaultKill) {
|
|
|
93
93
|
fs.rmSync(kitPidPath(paths), { force: true });
|
|
94
94
|
return { pid, killed };
|
|
95
95
|
}
|
|
96
|
-
|
|
96
|
+
function fallbackCommand(host) {
|
|
97
97
|
return { cmd: host.nodeBinary, args: [host.hqBinary, "agent", "kit", "run", "all"] };
|
|
98
98
|
}
|
|
99
99
|
/** crontab line that restarts the fallback supervisor after a reboot. */
|
|
@@ -9,8 +9,6 @@
|
|
|
9
9
|
* always drain exactly what it has not handled yet and never reply twice.
|
|
10
10
|
*/
|
|
11
11
|
import type { AgentKitPaths } from "./paths.js";
|
|
12
|
-
export declare const DONE_IDS_NAME = "done-ids.json";
|
|
13
|
-
export declare const MAX_DONE_IDS = 5000;
|
|
14
12
|
export interface MirroredInboxEntry {
|
|
15
13
|
id: string;
|
|
16
14
|
mirroredAt?: string;
|