@indigoai-us/hq-cli 5.183.0 → 5.185.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 +19 -0
- package/dist/command-catalog.generated.d.ts +3 -0
- package/dist/command-catalog.generated.js +4 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +100 -6
- package/dist/commands/mesh.js +18 -1
- package/dist/lib/bot/progress.d.ts +10 -0
- package/dist/lib/bot/progress.js +55 -0
- package/dist/lib/bot/prompt.js +3 -1
- package/dist/lib/bot/run.d.ts +4 -1
- package/dist/lib/bot/run.js +60 -5
- package/dist/lib/doctor/checks/sync-health.js +13 -0
- package/dist/lib/doctor/client-health-reason-codes-flag.d.ts +26 -0
- package/dist/lib/doctor/client-health-reason-codes-flag.js +94 -0
- package/dist/lib/doctor/fix/apply.d.ts +5 -0
- package/dist/lib/doctor/fix/apply.js +28 -3
- package/dist/lib/doctor/fix/json-output.d.ts +20 -0
- package/dist/lib/doctor/fix/json-output.js +22 -0
- package/dist/lib/doctor/fix/refresh-sync-outcome.d.ts +12 -0
- package/dist/lib/doctor/fix/refresh-sync-outcome.js +25 -0
- package/dist/lib/doctor/json-output.d.ts +7 -1
- package/dist/lib/doctor/json-output.js +6 -2
- package/dist/lib/doctor/types.d.ts +5 -0
- package/dist/lib/doctor/types.js +15 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +1 -1
- package/dist/lib/mesh/live/daemon/index.js +1 -1
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +7 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +25 -3
- package/dist/lib/mesh/live/emit-client.d.ts +6 -0
- package/dist/lib/mesh/live/emit-client.js +8 -0
- package/dist/lib/mesh/live/enqueue.d.ts +5 -0
- package/dist/lib/mesh/live/enqueue.js +19 -0
- package/dist/lib/mesh/live/flush.js +5 -3
- package/dist/lib/mesh/live/format-spool-line.d.ts +5 -2
- package/dist/lib/mesh/live/format-spool-line.js +10 -0
- package/dist/lib/mesh/live/report-summary.d.ts +45 -0
- package/dist/lib/mesh/live/report-summary.js +222 -0
- package/dist/lib/mesh/live/session-event.schema.json +24 -1
- package/dist/lib/mesh/live/validate-session-event.d.ts +1 -1
- package/dist/lib/mesh/live/validate-session-event.js +1 -1
- package/dist/lib/search-index/collection-path-repair-flag.d.ts +4 -0
- package/dist/lib/search-index/collection-path-repair-flag.js +7 -0
- package/dist/lib/search-index/index.d.ts +4 -0
- package/dist/lib/search-index/index.js +106 -1
- package/package.json +1 -1
|
@@ -23,6 +23,11 @@ export interface EnqueueOptions {
|
|
|
23
23
|
status?: "queued" | "in_progress" | "review" | "done";
|
|
24
24
|
reason?: string;
|
|
25
25
|
summary?: string;
|
|
26
|
+
repo?: string;
|
|
27
|
+
gitBranch?: string;
|
|
28
|
+
prUrls?: string[];
|
|
29
|
+
/** Local only: used to build a session_end summary; never written to the spool. */
|
|
30
|
+
transcriptPath?: string;
|
|
26
31
|
cwd?: string;
|
|
27
32
|
hqRoot?: string;
|
|
28
33
|
companySlug?: string;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Local session-event enqueue (US-008): validate + append one spool line.
|
|
3
3
|
* No network. Shared line format with work-mesh-enqueue.sh.
|
|
4
4
|
*/
|
|
5
|
+
import { enrichSessionEndEvent } from "./report-summary.js";
|
|
5
6
|
import { validateSessionEvent } from "./validate-session-event.js";
|
|
6
7
|
import { formatSpoolLine, } from "./format-spool-line.js";
|
|
7
8
|
import { generateUlid } from "./ulid.js";
|
|
@@ -65,6 +66,12 @@ export function enqueueSessionEvent(opts) {
|
|
|
65
66
|
event.reason = opts.reason;
|
|
66
67
|
if (opts.summary)
|
|
67
68
|
event.summary = opts.summary;
|
|
69
|
+
if (opts.repo)
|
|
70
|
+
event.repo = opts.repo;
|
|
71
|
+
if (opts.gitBranch)
|
|
72
|
+
event.gitBranch = opts.gitBranch;
|
|
73
|
+
if (opts.prUrls && opts.prUrls.length > 0)
|
|
74
|
+
event.prUrls = opts.prUrls;
|
|
68
75
|
if (opts.cwd)
|
|
69
76
|
event.cwd = opts.cwd;
|
|
70
77
|
if (opts.hqRoot)
|
|
@@ -77,6 +84,18 @@ export function enqueueSessionEvent(opts) {
|
|
|
77
84
|
event.task = opts.task;
|
|
78
85
|
if (opts.toolWrites !== undefined)
|
|
79
86
|
event.toolWrites = opts.toolWrites;
|
|
87
|
+
if (event.kind === "session_end") {
|
|
88
|
+
const enriched = enrichSessionEndEvent(event, { transcriptPath: opts.transcriptPath });
|
|
89
|
+
if (typeof enriched.summary === "string")
|
|
90
|
+
event.summary = enriched.summary;
|
|
91
|
+
if (typeof enriched.repo === "string")
|
|
92
|
+
event.repo = enriched.repo;
|
|
93
|
+
if (typeof enriched.gitBranch === "string")
|
|
94
|
+
event.gitBranch = enriched.gitBranch;
|
|
95
|
+
if (Array.isArray(enriched.prUrls) && enriched.prUrls.length > 0) {
|
|
96
|
+
event.prUrls = enriched.prUrls;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
80
99
|
const candidate = {
|
|
81
100
|
...event,
|
|
82
101
|
v: 1,
|
|
@@ -20,6 +20,7 @@ import { repairAckedRegisterStates } from "../../work-context/outbox.js";
|
|
|
20
20
|
import { workContextRoot } from "../../work-context/paths.js";
|
|
21
21
|
import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.js";
|
|
22
22
|
import { LOCAL_ONLY_FIELDS, stripLocalOnlyFields } from "./format-spool-line.js";
|
|
23
|
+
import { enrichSessionEndEvent } from "./report-summary.js";
|
|
23
24
|
import { SESSION_EVENTS_BATCH_MAX, WRITABLE_CONTEXT_STATUSES, parseSessionEventsBatchAck, } from "./session-events-client.js";
|
|
24
25
|
import { appendDeadLetterLine, appendDroppedReceipt, appendHeldLine, appendSpoolLine, claimHeld, claimSpool, countJsonlLines, ensureSpoolFile, recoverOrphanClaims, removeClaimFile, } from "./spool.js";
|
|
25
26
|
import { workMeshHeldPath, workMeshRoot } from "./paths.js";
|
|
@@ -316,7 +317,7 @@ export async function flushSessionEvents(deps) {
|
|
|
316
317
|
// presence-leave). Untouched events stay in pendingRestore and are put
|
|
317
318
|
// back on the spool below.
|
|
318
319
|
const flushItems = remainingItems.slice(0, SESSION_EVENTS_MAX_PER_SESSION_PER_FLUSH);
|
|
319
|
-
const stripped = flushItems.map((item) => stripLocalOnlyFields(item.event));
|
|
320
|
+
const stripped = flushItems.map((item) => stripLocalOnlyFields(enrichSessionEndEvent(item.event)));
|
|
320
321
|
for (let i = 0; i < stripped.length; i += SESSION_EVENTS_BATCH_MAX) {
|
|
321
322
|
const chunk = stripped.slice(i, i + SESSION_EVENTS_BATCH_MAX);
|
|
322
323
|
const originalChunk = flushItems.slice(i, i + SESSION_EVENTS_BATCH_MAX);
|
|
@@ -608,7 +609,7 @@ function synthesizeSessionEnd(sessionId, sample, seq, at) {
|
|
|
608
609
|
const harness = harnessRaw && SESSION_EVENT_HARNESSES.has(harnessRaw)
|
|
609
610
|
? harnessRaw
|
|
610
611
|
: "claude-code";
|
|
611
|
-
return stripLocalOnlyFields({
|
|
612
|
+
return stripLocalOnlyFields(enrichSessionEndEvent({
|
|
612
613
|
v: 1,
|
|
613
614
|
eventId: generateUlid(),
|
|
614
615
|
kind: "session_end",
|
|
@@ -617,7 +618,8 @@ function synthesizeSessionEnd(sessionId, sample, seq, at) {
|
|
|
617
618
|
adapterVersion: eventText(sample, "adapterVersion") || "1.0.0",
|
|
618
619
|
at,
|
|
619
620
|
seq,
|
|
620
|
-
|
|
621
|
+
cwd: eventText(sample, "cwd"),
|
|
622
|
+
}));
|
|
621
623
|
}
|
|
622
624
|
async function postPresenceLeaveIfNeeded(input) {
|
|
623
625
|
const leave = input.state?.presenceLeave;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export declare const LOCAL_ONLY_FIELDS: readonly ["cwd", "hqRoot", "companySlug", "project", "task", "toolWrites"];
|
|
6
6
|
export type LocalOnlyField = (typeof LOCAL_ONLY_FIELDS)[number];
|
|
7
7
|
/** Network + local key order matching work-mesh-enqueue.sh. */
|
|
8
|
-
export declare const SPOOL_KEY_ORDER: readonly ["v", "eventId", "kind", "sessionId", "harness", "adapterVersion", "runtimeVersion", "source", "at", "seq", "taskId", "status", "reason", "summary", "cwd", "hqRoot", "companySlug", "project", "task", "toolWrites"];
|
|
8
|
+
export declare const SPOOL_KEY_ORDER: readonly ["v", "eventId", "kind", "sessionId", "harness", "adapterVersion", "runtimeVersion", "source", "at", "seq", "taskId", "status", "reason", "summary", "repo", "gitBranch", "prUrls", "cwd", "hqRoot", "companySlug", "project", "task", "toolWrites"];
|
|
9
9
|
export type SessionEventKind = "session_start" | "turn_start" | "turn_end" | "session_end" | "task_status" | "blocked" | "note";
|
|
10
10
|
export type SessionEventHarness = "claude-code" | "claude-desktop" | "codex" | "grok" | "hq-sessions" | "agent-box" | "agents-v2";
|
|
11
11
|
export type SessionEventSource = "hooks" | "transcript";
|
|
@@ -24,6 +24,9 @@ export interface SpoolEventInput {
|
|
|
24
24
|
status?: "queued" | "in_progress" | "review" | "done";
|
|
25
25
|
reason?: string;
|
|
26
26
|
summary?: string;
|
|
27
|
+
repo?: string;
|
|
28
|
+
gitBranch?: string;
|
|
29
|
+
prUrls?: string[];
|
|
27
30
|
cwd?: string;
|
|
28
31
|
hqRoot?: string;
|
|
29
32
|
companySlug?: string;
|
|
@@ -35,7 +38,7 @@ export interface SpoolEventInput {
|
|
|
35
38
|
* Build a compact JSON object in SPOOL_KEY_ORDER, omitting absent optional
|
|
36
39
|
* strings. `toolWrites` is included when it is a finite number >= 0 (including 0).
|
|
37
40
|
*/
|
|
38
|
-
export declare function buildOrderedSpoolObject(input: SpoolEventInput): Record<string, string | number>;
|
|
41
|
+
export declare function buildOrderedSpoolObject(input: SpoolEventInput): Record<string, string | number | string[]>;
|
|
39
42
|
/** Compact JSON line body (no trailing newline). */
|
|
40
43
|
export declare function formatSpoolLine(input: SpoolEventInput): string;
|
|
41
44
|
export declare function stripLocalOnlyFields(event: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -26,6 +26,9 @@ export const SPOOL_KEY_ORDER = [
|
|
|
26
26
|
"status",
|
|
27
27
|
"reason",
|
|
28
28
|
"summary",
|
|
29
|
+
"repo",
|
|
30
|
+
"gitBranch",
|
|
31
|
+
"prUrls",
|
|
29
32
|
"cwd",
|
|
30
33
|
"hqRoot",
|
|
31
34
|
"companySlug",
|
|
@@ -63,6 +66,13 @@ export function buildOrderedSpoolObject(input) {
|
|
|
63
66
|
out.reason = input.reason;
|
|
64
67
|
if (includeString(input.summary))
|
|
65
68
|
out.summary = input.summary;
|
|
69
|
+
if (includeString(input.repo))
|
|
70
|
+
out.repo = input.repo;
|
|
71
|
+
if (includeString(input.gitBranch))
|
|
72
|
+
out.gitBranch = input.gitBranch;
|
|
73
|
+
if (Array.isArray(input.prUrls) && input.prUrls.length > 0) {
|
|
74
|
+
out.prUrls = input.prUrls.filter(includeString).slice(0, 5);
|
|
75
|
+
}
|
|
66
76
|
if (includeString(input.cwd))
|
|
67
77
|
out.cwd = input.cwd;
|
|
68
78
|
if (includeString(input.hqRoot))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-051 — one-sentence quiet-report fields for session_end.
|
|
3
|
+
*
|
|
4
|
+
* Built locally at the quiet trigger / SessionEnd flush. Never includes file
|
|
5
|
+
* contents. Tokens, secret-looking strings, and secrets-file paths are redacted.
|
|
6
|
+
*/
|
|
7
|
+
export declare const REPORT_SUMMARY_MAX_CHARS = 140;
|
|
8
|
+
export declare const REPORT_SUMMARY_SCHEMA_MAX = 280;
|
|
9
|
+
export declare const REPORT_TRANSCRIPT_TAIL_BYTES: number;
|
|
10
|
+
export interface SessionEndReportFields {
|
|
11
|
+
summary?: string;
|
|
12
|
+
repo?: string;
|
|
13
|
+
gitBranch?: string;
|
|
14
|
+
prUrls?: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface BuildSessionEndReportFieldsInput {
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
cwd?: string;
|
|
19
|
+
transcriptPath?: string;
|
|
20
|
+
home?: string;
|
|
21
|
+
/** Injectable git runner (tests). */
|
|
22
|
+
git?: (cwd: string, args: string[]) => string | undefined;
|
|
23
|
+
/** Injectable transcript reader (tests). */
|
|
24
|
+
readTranscript?: (p: string) => string | undefined;
|
|
25
|
+
}
|
|
26
|
+
export declare function redactReportSummary(text: string): string;
|
|
27
|
+
export declare function firstSentence(text: string, max?: number): string;
|
|
28
|
+
export declare function extractLastAssistantText(jsonl: string): string | undefined;
|
|
29
|
+
export declare function readTranscriptTail(transcriptPath: string, maxBytes?: number): string | undefined;
|
|
30
|
+
export declare function collectGitContext(cwd: string, git?: (cwd: string, args: string[]) => string | undefined): {
|
|
31
|
+
repo?: string;
|
|
32
|
+
gitBranch?: string;
|
|
33
|
+
};
|
|
34
|
+
export declare function extractPrUrls(text: string, max?: number): string[];
|
|
35
|
+
export declare function claudeTranscriptPathFor(sessionId: string, cwd: string, home?: string): string;
|
|
36
|
+
export declare function resolveTranscriptPath(input: {
|
|
37
|
+
transcriptPath?: string;
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
cwd?: string;
|
|
40
|
+
home?: string;
|
|
41
|
+
}): string | undefined;
|
|
42
|
+
export declare function buildSessionEndReportFields(input: BuildSessionEndReportFieldsInput): SessionEndReportFields;
|
|
43
|
+
/** Merge report fields onto a session_end event object (no-op for other kinds). */
|
|
44
|
+
export declare function enrichSessionEndEvent(event: Record<string, unknown>, extra?: Pick<BuildSessionEndReportFieldsInput, "transcriptPath" | "home" | "git" | "readTranscript">): Record<string, unknown>;
|
|
45
|
+
//# sourceMappingURL=report-summary.d.ts.map
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-051 — one-sentence quiet-report fields for session_end.
|
|
3
|
+
*
|
|
4
|
+
* Built locally at the quiet trigger / SessionEnd flush. Never includes file
|
|
5
|
+
* contents. Tokens, secret-looking strings, and secrets-file paths are redacted.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
export const REPORT_SUMMARY_MAX_CHARS = 140;
|
|
12
|
+
export const REPORT_SUMMARY_SCHEMA_MAX = 280;
|
|
13
|
+
export const REPORT_TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
|
14
|
+
const SECRET_RE = /(?:Bearer\s+[A-Za-z0-9._\-+=/]+)|(?:sk-[A-Za-z0-9]{8,})|(?:ghp_[A-Za-z0-9]{8,})|(?:github_pat_[A-Za-z0-9_]{8,})|(?:xox[baprs]-[A-Za-z0-9-]{8,})|(?:AKIA[0-9A-Z]{8,})|(?:op:\/\/[^\s"]+)/gi;
|
|
15
|
+
const SECRETS_PATH_RE = /(?:^|[\s"'`])((?:~|\/|\.\/|\.\.\/)?(?:[\w.-]+\/)*(?:\.env(?:\.[A-Za-z0-9._-]+)?|secrets(?:\/[\w.-]+)*|id_rsa|credentials(?:\.json)?|\.netrc|\.npmrc))/gi;
|
|
16
|
+
const PR_URL_RE = /https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+/g;
|
|
17
|
+
export function redactReportSummary(text) {
|
|
18
|
+
let out = text.replace(SECRET_RE, "[redacted]");
|
|
19
|
+
out = out.replace(SECRETS_PATH_RE, (full, match) => full.replace(match, "[redacted-path]"));
|
|
20
|
+
// eslint-disable-next-line no-control-regex -- strip C0 controls from report text
|
|
21
|
+
return out.replace(/[\u0000-\u001F\u007F]/g, " ").replace(/\s+/g, " ").trim();
|
|
22
|
+
}
|
|
23
|
+
export function firstSentence(text, max = REPORT_SUMMARY_MAX_CHARS) {
|
|
24
|
+
const cleaned = redactReportSummary(text);
|
|
25
|
+
if (!cleaned)
|
|
26
|
+
return "";
|
|
27
|
+
const cut = cleaned.split(/(?<=[.!?])\s+/)[0] ?? cleaned;
|
|
28
|
+
const sentence = cut.trim();
|
|
29
|
+
if (sentence.length <= max)
|
|
30
|
+
return sentence;
|
|
31
|
+
const slice = sentence.slice(0, max);
|
|
32
|
+
const lastSpace = slice.lastIndexOf(" ");
|
|
33
|
+
return (lastSpace > 40 ? slice.slice(0, lastSpace) : slice).trim();
|
|
34
|
+
}
|
|
35
|
+
function textFromUnknown(value) {
|
|
36
|
+
if (typeof value === "string")
|
|
37
|
+
return value;
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return value
|
|
40
|
+
.map((item) => {
|
|
41
|
+
if (typeof item === "string")
|
|
42
|
+
return item;
|
|
43
|
+
if (item && typeof item === "object") {
|
|
44
|
+
const rec = item;
|
|
45
|
+
if (rec.type === "text" && typeof rec.text === "string")
|
|
46
|
+
return rec.text;
|
|
47
|
+
}
|
|
48
|
+
return "";
|
|
49
|
+
})
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.join("\n");
|
|
52
|
+
}
|
|
53
|
+
if (value && typeof value === "object") {
|
|
54
|
+
const rec = value;
|
|
55
|
+
if (typeof rec.text === "string")
|
|
56
|
+
return rec.text;
|
|
57
|
+
if (rec.content !== undefined)
|
|
58
|
+
return textFromUnknown(rec.content);
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
export function extractLastAssistantText(jsonl) {
|
|
63
|
+
let last;
|
|
64
|
+
for (const rawLine of jsonl.split(/\r?\n/)) {
|
|
65
|
+
const line = rawLine.trim();
|
|
66
|
+
if (!line.startsWith("{"))
|
|
67
|
+
continue;
|
|
68
|
+
let obj;
|
|
69
|
+
try {
|
|
70
|
+
obj = JSON.parse(line);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const type = typeof obj.type === "string" ? obj.type : "";
|
|
76
|
+
const role = typeof obj.role === "string" ? obj.role : "";
|
|
77
|
+
const payload = obj.payload;
|
|
78
|
+
let text = "";
|
|
79
|
+
if (type === "assistant" || role === "assistant") {
|
|
80
|
+
text = textFromUnknown(obj.message ?? obj.content);
|
|
81
|
+
}
|
|
82
|
+
else if (payload &&
|
|
83
|
+
(payload.type === "agent_message" || payload.type === "assistant")) {
|
|
84
|
+
text = textFromUnknown(payload.message ?? payload.content ?? payload.text);
|
|
85
|
+
}
|
|
86
|
+
const trimmed = text.trim();
|
|
87
|
+
if (trimmed)
|
|
88
|
+
last = trimmed;
|
|
89
|
+
}
|
|
90
|
+
return last;
|
|
91
|
+
}
|
|
92
|
+
export function readTranscriptTail(transcriptPath, maxBytes = REPORT_TRANSCRIPT_TAIL_BYTES) {
|
|
93
|
+
try {
|
|
94
|
+
const st = fs.statSync(transcriptPath);
|
|
95
|
+
if (!st.isFile() || st.size <= 0)
|
|
96
|
+
return undefined;
|
|
97
|
+
const size = Math.min(st.size, maxBytes);
|
|
98
|
+
const fd = fs.openSync(transcriptPath, "r");
|
|
99
|
+
try {
|
|
100
|
+
const buf = Buffer.alloc(size);
|
|
101
|
+
fs.readSync(fd, buf, 0, size, Math.max(0, st.size - size));
|
|
102
|
+
let text = buf.toString("utf8");
|
|
103
|
+
if (st.size > maxBytes) {
|
|
104
|
+
const nl = text.indexOf("\n");
|
|
105
|
+
if (nl >= 0)
|
|
106
|
+
text = text.slice(nl + 1);
|
|
107
|
+
}
|
|
108
|
+
return text;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
fs.closeSync(fd);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function defaultGit(cwd, args) {
|
|
119
|
+
try {
|
|
120
|
+
const r = spawnSync("git", ["-C", cwd, ...args], {
|
|
121
|
+
timeout: 800,
|
|
122
|
+
encoding: "utf8",
|
|
123
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
124
|
+
});
|
|
125
|
+
if (r.status !== 0)
|
|
126
|
+
return undefined;
|
|
127
|
+
const out = (r.stdout ?? "").trim();
|
|
128
|
+
return out || undefined;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export function collectGitContext(cwd, git = defaultGit) {
|
|
135
|
+
const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
136
|
+
const top = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
137
|
+
const repo = top ? path.basename(top) : undefined;
|
|
138
|
+
const out = {};
|
|
139
|
+
if (repo && repo.length <= 128 && !repo.includes("\0"))
|
|
140
|
+
out.repo = repo;
|
|
141
|
+
if (branch && branch !== "HEAD" && branch.length <= 128)
|
|
142
|
+
out.gitBranch = branch;
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
export function extractPrUrls(text, max = 5) {
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
const urls = [];
|
|
148
|
+
for (const match of text.match(PR_URL_RE) ?? []) {
|
|
149
|
+
if (seen.has(match))
|
|
150
|
+
continue;
|
|
151
|
+
seen.add(match);
|
|
152
|
+
urls.push(match.slice(0, 256));
|
|
153
|
+
if (urls.length >= max)
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
return urls;
|
|
157
|
+
}
|
|
158
|
+
export function claudeTranscriptPathFor(sessionId, cwd, home = os.homedir()) {
|
|
159
|
+
const encoded = cwd.startsWith("/") ? cwd.replace(/\//g, "-") : `-${cwd.replace(/\//g, "-")}`;
|
|
160
|
+
return path.join(home, ".claude", "projects", encoded, `${sessionId}.jsonl`);
|
|
161
|
+
}
|
|
162
|
+
export function resolveTranscriptPath(input) {
|
|
163
|
+
const explicit = input.transcriptPath?.trim();
|
|
164
|
+
if (explicit)
|
|
165
|
+
return explicit;
|
|
166
|
+
const envPath = process.env.CLAUDE_TRANSCRIPT_PATH?.trim();
|
|
167
|
+
if (envPath)
|
|
168
|
+
return envPath;
|
|
169
|
+
const sid = input.sessionId?.trim();
|
|
170
|
+
const cwd = input.cwd?.trim();
|
|
171
|
+
if (sid && cwd)
|
|
172
|
+
return claudeTranscriptPathFor(sid, cwd, input.home);
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
export function buildSessionEndReportFields(input) {
|
|
176
|
+
const fields = {};
|
|
177
|
+
const git = input.git ?? defaultGit;
|
|
178
|
+
const cwd = input.cwd?.trim();
|
|
179
|
+
if (cwd)
|
|
180
|
+
Object.assign(fields, collectGitContext(cwd, git));
|
|
181
|
+
const transcriptPath = resolveTranscriptPath(input);
|
|
182
|
+
const read = input.readTranscript ?? ((p) => readTranscriptTail(p));
|
|
183
|
+
let assistant;
|
|
184
|
+
if (transcriptPath) {
|
|
185
|
+
const tail = read(transcriptPath);
|
|
186
|
+
if (tail)
|
|
187
|
+
assistant = extractLastAssistantText(tail);
|
|
188
|
+
}
|
|
189
|
+
if (assistant) {
|
|
190
|
+
const summary = firstSentence(assistant);
|
|
191
|
+
if (summary)
|
|
192
|
+
fields.summary = summary.slice(0, REPORT_SUMMARY_SCHEMA_MAX);
|
|
193
|
+
const prs = extractPrUrls(assistant);
|
|
194
|
+
if (prs.length > 0)
|
|
195
|
+
fields.prUrls = prs;
|
|
196
|
+
}
|
|
197
|
+
return fields;
|
|
198
|
+
}
|
|
199
|
+
/** Merge report fields onto a session_end event object (no-op for other kinds). */
|
|
200
|
+
export function enrichSessionEndEvent(event, extra) {
|
|
201
|
+
if (event.kind !== "session_end")
|
|
202
|
+
return event;
|
|
203
|
+
const built = buildSessionEndReportFields({
|
|
204
|
+
sessionId: typeof event.sessionId === "string" ? event.sessionId : undefined,
|
|
205
|
+
cwd: typeof event.cwd === "string" ? event.cwd : undefined,
|
|
206
|
+
transcriptPath: extra?.transcriptPath,
|
|
207
|
+
home: extra?.home,
|
|
208
|
+
git: extra?.git,
|
|
209
|
+
readTranscript: extra?.readTranscript,
|
|
210
|
+
});
|
|
211
|
+
const out = { ...event };
|
|
212
|
+
if (!out.summary && built.summary)
|
|
213
|
+
out.summary = built.summary;
|
|
214
|
+
if (!out.repo && built.repo)
|
|
215
|
+
out.repo = built.repo;
|
|
216
|
+
if (!out.gitBranch && built.gitBranch)
|
|
217
|
+
out.gitBranch = built.gitBranch;
|
|
218
|
+
if (!out.prUrls && built.prUrls)
|
|
219
|
+
out.prUrls = built.prUrls;
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
//# sourceMappingURL=report-summary.js.map
|
|
@@ -109,7 +109,30 @@
|
|
|
109
109
|
"type": "string",
|
|
110
110
|
"minLength": 1,
|
|
111
111
|
"maxLength": 280,
|
|
112
|
-
"description": "Optional short summary
|
|
112
|
+
"description": "Optional short summary on note, blocked, task_status, and session_end (US-051 quiet report). Redacted; never raw prompts, tokens, or file contents."
|
|
113
|
+
},
|
|
114
|
+
"repo": {
|
|
115
|
+
"type": "string",
|
|
116
|
+
"minLength": 1,
|
|
117
|
+
"maxLength": 128,
|
|
118
|
+
"description": "Optional git repository basename on session_end for the quiet work report (US-051)."
|
|
119
|
+
},
|
|
120
|
+
"gitBranch": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"minLength": 1,
|
|
123
|
+
"maxLength": 128,
|
|
124
|
+
"description": "Optional git branch of the session cwd on session_end (US-051)."
|
|
125
|
+
},
|
|
126
|
+
"prUrls": {
|
|
127
|
+
"type": "array",
|
|
128
|
+
"maxItems": 5,
|
|
129
|
+
"items": {
|
|
130
|
+
"type": "string",
|
|
131
|
+
"minLength": 1,
|
|
132
|
+
"maxLength": 256,
|
|
133
|
+
"pattern": "^https://github\\.com/"
|
|
134
|
+
},
|
|
135
|
+
"description": "Optional pull-request URLs seen in the quiet window (US-051)."
|
|
113
136
|
},
|
|
114
137
|
"cwd": {
|
|
115
138
|
"type": "string",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { ValidateFunction } from "ajv";
|
|
9
9
|
export declare const SESSION_EVENT_SCHEMA_PATH: string;
|
|
10
10
|
/** Expected SHA-256 of the canonical schema bytes (hq-pro + hq-cli must match). */
|
|
11
|
-
export declare const SESSION_EVENT_SCHEMA_SHA256 = "
|
|
11
|
+
export declare const SESSION_EVENT_SCHEMA_SHA256 = "e3a9b05daf0037688809e8825a286f9dc89d69aa9266696270d776b64bf99dda";
|
|
12
12
|
export declare const PROHIBITED_CONTENT_CLASSES: readonly ["prompts", "model_output", "transcripts", "message_bodies", "tokens", "credentials"];
|
|
13
13
|
export type ProhibitedContentClass = (typeof PROHIBITED_CONTENT_CLASSES)[number];
|
|
14
14
|
/** Fixture field name → prohibited class. */
|
|
@@ -13,7 +13,7 @@ import Ajv2020 from "ajv/dist/2020.js";
|
|
|
13
13
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
export const SESSION_EVENT_SCHEMA_PATH = join(HERE, "session-event.schema.json");
|
|
15
15
|
/** Expected SHA-256 of the canonical schema bytes (hq-pro + hq-cli must match). */
|
|
16
|
-
export const SESSION_EVENT_SCHEMA_SHA256 = "
|
|
16
|
+
export const SESSION_EVENT_SCHEMA_SHA256 = "e3a9b05daf0037688809e8825a286f9dc89d69aa9266696270d776b64bf99dda";
|
|
17
17
|
export const PROHIBITED_CONTENT_CLASSES = [
|
|
18
18
|
"prompts",
|
|
19
19
|
"model_output",
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const QMD_COLLECTION_PATH_REPAIR_FLAG = "qmd.collection-path-repair";
|
|
2
|
+
/** Keep collection-path repair off unless the hq-flags registry enables a canary. */
|
|
3
|
+
export declare function qmdCollectionPathRepairEnabled(): boolean;
|
|
4
|
+
//# sourceMappingURL=collection-path-repair-flag.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { resolveProcessFlagGate } from '../flag-registry.js';
|
|
2
|
+
export const QMD_COLLECTION_PATH_REPAIR_FLAG = 'qmd.collection-path-repair';
|
|
3
|
+
/** Keep collection-path repair off unless the hq-flags registry enables a canary. */
|
|
4
|
+
export function qmdCollectionPathRepairEnabled() {
|
|
5
|
+
return resolveProcessFlagGate(QMD_COLLECTION_PATH_REPAIR_FLAG, { fallback: false }, () => false);
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=collection-path-repair-flag.js.map
|
|
@@ -537,10 +537,14 @@ export declare function deriveCollections(hqRoot: string): SearchCollection[];
|
|
|
537
537
|
export type ReconcileCollectionsOptions = {
|
|
538
538
|
bin?: string;
|
|
539
539
|
runner?: QmdProcessRunner;
|
|
540
|
+
/** Test seam for the default-off collection-path repair rollout. */
|
|
541
|
+
repairPathDrift?: boolean;
|
|
540
542
|
};
|
|
541
543
|
/**
|
|
542
544
|
* Register expected collections that qmd does not yet know about, returning the
|
|
543
545
|
* collections this call actually created (a `collection add` that exited 0).
|
|
546
|
+
* When the default-off hq-flags rollout is enabled, it also replaces a registered
|
|
547
|
+
* collection whose path matches a known prior HQ-managed layout.
|
|
544
548
|
*
|
|
545
549
|
* A same-name idempotent duplicate — qmd already owns the exact requested name
|
|
546
550
|
* in its YAML config (`Collection '<name>' already exists.`), while the
|
|
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { dump, load } from 'js-yaml';
|
|
6
7
|
import { isQmdModelDownloadError } from '../../utils/qmd-model-download-error.js';
|
|
7
8
|
import { isQmdModuleMissingError } from '../../utils/qmd-module-missing-error.js';
|
|
8
9
|
import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
|
|
@@ -12,6 +13,8 @@ import { isQmdStoreUnopenableError } from '../../utils/qmd-store-unopenable-erro
|
|
|
12
13
|
import { markExpectedUserError } from '../../utils/expected-cli-error.js';
|
|
13
14
|
import { redactErrorText } from '../../utils/redact-error-text.js';
|
|
14
15
|
import { planCommandSpawn } from '../../utils/windows-spawn.js';
|
|
16
|
+
import { resolveQmdConfigPath } from './max-doc-bytes.js';
|
|
17
|
+
import { qmdCollectionPathRepairEnabled } from './collection-path-repair-flag.js';
|
|
15
18
|
/** The `path` implementation for a (possibly injected) platform. */
|
|
16
19
|
function pathFor(platform) {
|
|
17
20
|
return platform === 'win32' ? path.win32 : path.posix;
|
|
@@ -1350,9 +1353,90 @@ export function deriveCollections(hqRoot) {
|
|
|
1350
1353
|
}
|
|
1351
1354
|
return collections;
|
|
1352
1355
|
}
|
|
1356
|
+
function isRecord(value) {
|
|
1357
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1358
|
+
}
|
|
1359
|
+
function qmdConfigPath(hqRoot) {
|
|
1360
|
+
const env = {
|
|
1361
|
+
...process.env,
|
|
1362
|
+
HOME: process.env.HOME ?? process.env.USERPROFILE ?? os.homedir(),
|
|
1363
|
+
};
|
|
1364
|
+
return resolveQmdConfigPath({ env, startDir: hqRoot });
|
|
1365
|
+
}
|
|
1366
|
+
/** Read qmd's configured collection metadata from the same config file its CLI uses. */
|
|
1367
|
+
function configuredCollections(hqRoot) {
|
|
1368
|
+
const configPath = qmdConfigPath(hqRoot);
|
|
1369
|
+
if (!configPath || !fs.existsSync(configPath))
|
|
1370
|
+
return new Map();
|
|
1371
|
+
const document = load(fs.readFileSync(configPath, 'utf8'));
|
|
1372
|
+
if (!isRecord(document) || !isRecord(document.collections))
|
|
1373
|
+
return new Map();
|
|
1374
|
+
return new Map(Object.entries(document.collections).flatMap(([name, config]) => isRecord(config) && typeof config.path === 'string' ? [[name, config]] : []));
|
|
1375
|
+
}
|
|
1376
|
+
/** Restore existing collection options after qmd's add command rewrites the entry. */
|
|
1377
|
+
function restoreCollectionMetadata(hqRoot, collection, previousConfig) {
|
|
1378
|
+
const configPath = qmdConfigPath(hqRoot);
|
|
1379
|
+
if (!configPath || !fs.existsSync(configPath)) {
|
|
1380
|
+
throw new Error(`QMD config disappeared while repairing collection '${collection.name}'`);
|
|
1381
|
+
}
|
|
1382
|
+
const document = load(fs.readFileSync(configPath, 'utf8'));
|
|
1383
|
+
if (!isRecord(document) || !isRecord(document.collections)) {
|
|
1384
|
+
throw new Error(`QMD config has no collections map while repairing '${collection.name}'`);
|
|
1385
|
+
}
|
|
1386
|
+
const currentConfig = document.collections[collection.name];
|
|
1387
|
+
document.collections[collection.name] = {
|
|
1388
|
+
...(isRecord(currentConfig) ? currentConfig : {}),
|
|
1389
|
+
...previousConfig,
|
|
1390
|
+
path: collection.path,
|
|
1391
|
+
};
|
|
1392
|
+
fs.writeFileSync(configPath, dump(document, { lineWidth: 0 }));
|
|
1393
|
+
}
|
|
1394
|
+
function resolvedPath(value) {
|
|
1395
|
+
const absolutePath = path.resolve(value);
|
|
1396
|
+
try {
|
|
1397
|
+
return fs.realpathSync(absolutePath);
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
return absolutePath;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
function comparablePath(value) {
|
|
1404
|
+
return process.platform === 'win32' ? value.toLowerCase() : value;
|
|
1405
|
+
}
|
|
1406
|
+
/** Repair a known HQ-managed path, including the former company knowledge mirror layout. */
|
|
1407
|
+
function isPreviousHqManagedPath(hqRoot, collection, configuredPath) {
|
|
1408
|
+
const root = resolvedPath(hqRoot);
|
|
1409
|
+
const expectedPath = resolvedPath(collection.path);
|
|
1410
|
+
const managedRelativePath = path.relative(root, expectedPath);
|
|
1411
|
+
if (!managedRelativePath ||
|
|
1412
|
+
path.isAbsolute(managedRelativePath) ||
|
|
1413
|
+
managedRelativePath === '..' ||
|
|
1414
|
+
managedRelativePath.startsWith(`..${path.sep}`)) {
|
|
1415
|
+
return false;
|
|
1416
|
+
}
|
|
1417
|
+
const actualPath = resolvedPath(path.resolve(hqRoot, configuredPath));
|
|
1418
|
+
const suffix = `${path.sep}${managedRelativePath}`;
|
|
1419
|
+
const actualForComparison = comparablePath(actualPath);
|
|
1420
|
+
const suffixForComparison = comparablePath(suffix);
|
|
1421
|
+
const prefixLength = actualForComparison.length - suffixForComparison.length;
|
|
1422
|
+
if (prefixLength > path.parse(actualPath).root.length && actualForComparison.endsWith(suffixForComparison)) {
|
|
1423
|
+
return true;
|
|
1424
|
+
}
|
|
1425
|
+
const expectedParts = managedRelativePath.split(path.sep);
|
|
1426
|
+
if (expectedParts.length !== 3 || expectedParts[0] !== 'companies' || expectedParts[2] !== 'knowledge') {
|
|
1427
|
+
return false;
|
|
1428
|
+
}
|
|
1429
|
+
const slug = expectedParts[1];
|
|
1430
|
+
if (!slug)
|
|
1431
|
+
return false;
|
|
1432
|
+
const formerMirrorPath = resolvedPath(path.join(root, 'repos', 'private', `knowledge-${slug}`));
|
|
1433
|
+
return comparablePath(actualPath) === comparablePath(formerMirrorPath);
|
|
1434
|
+
}
|
|
1353
1435
|
/**
|
|
1354
1436
|
* Register expected collections that qmd does not yet know about, returning the
|
|
1355
1437
|
* collections this call actually created (a `collection add` that exited 0).
|
|
1438
|
+
* When the default-off hq-flags rollout is enabled, it also replaces a registered
|
|
1439
|
+
* collection whose path matches a known prior HQ-managed layout.
|
|
1356
1440
|
*
|
|
1357
1441
|
* A same-name idempotent duplicate — qmd already owns the exact requested name
|
|
1358
1442
|
* in its YAML config (`Collection '<name>' already exists.`), while the
|
|
@@ -1368,11 +1452,29 @@ export function deriveCollections(hqRoot) {
|
|
|
1368
1452
|
export function reconcileCollections(hqRoot, options = {}) {
|
|
1369
1453
|
const runOptions = { bin: options.bin, runner: options.runner, cwd: hqRoot };
|
|
1370
1454
|
const registered = listRegisteredCollections(hqRoot, runOptions);
|
|
1371
|
-
const
|
|
1455
|
+
const expected = deriveCollections(hqRoot);
|
|
1456
|
+
const repairPathDrift = options.repairPathDrift ?? qmdCollectionPathRepairEnabled();
|
|
1457
|
+
const configured = repairPathDrift ? configuredCollections(hqRoot) : new Map();
|
|
1458
|
+
const stale = repairPathDrift
|
|
1459
|
+
? expected.filter((collection) => {
|
|
1460
|
+
const previousConfig = configured.get(collection.name);
|
|
1461
|
+
return previousConfig !== undefined
|
|
1462
|
+
&& comparablePath(resolvedPath(path.resolve(hqRoot, previousConfig.path))) !== comparablePath(resolvedPath(collection.path))
|
|
1463
|
+
&& isPreviousHqManagedPath(hqRoot, collection, previousConfig.path);
|
|
1464
|
+
})
|
|
1465
|
+
: [];
|
|
1466
|
+
for (const collection of stale) {
|
|
1467
|
+
runQmd(['collection', 'remove', collection.name], runOptions);
|
|
1468
|
+
registered.delete(collection.name);
|
|
1469
|
+
}
|
|
1470
|
+
const missing = expected.filter((collection) => !registered.has(collection.name));
|
|
1372
1471
|
const created = [];
|
|
1373
1472
|
for (const collection of missing) {
|
|
1374
1473
|
try {
|
|
1375
1474
|
runQmd(['collection', 'add', collection.path, '--name', collection.name, '--mask', collection.mask], runOptions);
|
|
1475
|
+
const previousConfig = configured.get(collection.name);
|
|
1476
|
+
if (previousConfig)
|
|
1477
|
+
restoreCollectionMetadata(hqRoot, collection, previousConfig);
|
|
1376
1478
|
created.push(collection);
|
|
1377
1479
|
}
|
|
1378
1480
|
catch (error) {
|
|
@@ -1381,6 +1483,9 @@ export function reconcileCollections(hqRoot, options = {}) {
|
|
|
1381
1483
|
// different collection — stays fatal.
|
|
1382
1484
|
if (!(error instanceof QmdCollectionExistsError) || error.collectionName !== collection.name)
|
|
1383
1485
|
throw error;
|
|
1486
|
+
const previousConfig = configured.get(collection.name);
|
|
1487
|
+
if (previousConfig)
|
|
1488
|
+
restoreCollectionMetadata(hqRoot, collection, previousConfig);
|
|
1384
1489
|
}
|
|
1385
1490
|
runQmd(['context', 'add', `qmd://${collection.name}`, collection.context], runOptions);
|
|
1386
1491
|
}
|