@indigoai-us/hq-cloud 6.14.1 → 6.14.3
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/dist/bin/sync-runner-company.d.ts +4 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +50 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +21 -7
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +6 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +162 -11
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +155 -17
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/accept.d.ts.map +1 -1
- package/dist/cli/accept.js +29 -0
- package/dist/cli/accept.js.map +1 -1
- package/dist/cli/invite.d.ts.map +1 -1
- package/dist/cli/invite.js +33 -0
- package/dist/cli/invite.js.map +1 -1
- package/dist/cli/promote.d.ts.map +1 -1
- package/dist/cli/promote.js +33 -0
- package/dist/cli/promote.js.map +1 -1
- package/dist/cli/reindex.d.ts +23 -11
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +85 -12
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +95 -13
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/share.js +47 -4
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +123 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +84 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +44 -3
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +156 -7
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +133 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/company-resolver.d.ts +21 -0
- package/dist/company-resolver.d.ts.map +1 -1
- package/dist/company-resolver.js +31 -6
- package/dist/company-resolver.js.map +1 -1
- package/dist/company-resolver.test.js +44 -1
- package/dist/company-resolver.test.js.map +1 -1
- package/dist/ignore.d.ts +10 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +23 -2
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +13 -1
- package/dist/ignore.test.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +12 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +12 -0
- package/dist/journal.js.map +1 -1
- package/dist/lib/exit-codes.d.ts +25 -0
- package/dist/lib/exit-codes.d.ts.map +1 -0
- package/dist/lib/exit-codes.js +25 -0
- package/dist/lib/exit-codes.js.map +1 -0
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +45 -14
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +61 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +36 -6
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +34 -11
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +30 -0
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts +29 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +48 -2
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +44 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/s3.d.ts +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +75 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +56 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +12 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +12 -0
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +31 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +41 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +111 -7
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +187 -5
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +3 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +23 -0
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +21 -0
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +46 -13
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/telemetry-events.d.ts +31 -0
- package/dist/telemetry-events.d.ts.map +1 -0
- package/dist/telemetry-events.js +140 -0
- package/dist/telemetry-events.js.map +1 -0
- package/dist/telemetry-events.test.d.ts +2 -0
- package/dist/telemetry-events.test.d.ts.map +1 -0
- package/dist/telemetry-events.test.js +73 -0
- package/dist/telemetry-events.test.js.map +1 -0
- package/dist/telemetry.js +1 -1
- package/dist/telemetry.js.map +1 -1
- package/dist/vault-client.d.ts +38 -5
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +69 -6
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +31 -1
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +49 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +49 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +2 -0
- package/scripts/presign-transport-e2e.mjs +2 -2
- package/src/bin/sync-runner-company.ts +65 -1
- package/src/bin/sync-runner-watch-loop.ts +24 -8
- package/src/bin/sync-runner.test.ts +194 -16
- package/src/bin/sync-runner.ts +209 -11
- package/src/cli/accept.ts +27 -0
- package/src/cli/invite.ts +31 -0
- package/src/cli/promote.ts +31 -0
- package/src/cli/reindex.test.ts +113 -13
- package/src/cli/reindex.ts +123 -23
- package/src/cli/share.test.ts +139 -2
- package/src/cli/share.ts +48 -2
- package/src/cli/sync-scope.test.ts +90 -0
- package/src/cli/sync.test.ts +154 -0
- package/src/cli/sync.ts +224 -5
- package/src/company-resolver.test.ts +51 -0
- package/src/company-resolver.ts +40 -5
- package/src/ignore.test.ts +18 -1
- package/src/ignore.ts +24 -2
- package/src/index.ts +24 -1
- package/src/journal.ts +12 -0
- package/src/lib/exit-codes.ts +24 -0
- package/src/object-io.test.ts +66 -1
- package/src/object-io.ts +51 -14
- package/src/operation-lock.test.ts +33 -0
- package/src/operation-lock.ts +46 -11
- package/src/personal-vault.test.ts +57 -0
- package/src/personal-vault.ts +48 -2
- package/src/s3.test.ts +87 -0
- package/src/s3.ts +93 -2
- package/src/scope-shrink.test.ts +32 -0
- package/src/scope-shrink.ts +12 -0
- package/src/skill-telemetry.test.ts +247 -4
- package/src/skill-telemetry.ts +134 -6
- package/src/sync/event-sync.ts +37 -0
- package/src/sync/pull-scope.ts +62 -13
- package/src/telemetry-events.test.ts +88 -0
- package/src/telemetry-events.ts +205 -0
- package/src/telemetry.ts +1 -1
- package/src/vault-client.test.ts +39 -1
- package/src/vault-client.ts +115 -14
- package/src/watcher.test.ts +70 -0
- package/src/watcher.ts +68 -0
package/src/cli/accept.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
VaultPermissionDeniedError,
|
|
15
15
|
} from "../vault-client.js";
|
|
16
16
|
import type { Membership } from "../vault-client.js";
|
|
17
|
+
import { emitCloudTelemetry } from "../telemetry-events.js";
|
|
17
18
|
|
|
18
19
|
export interface AcceptOptions {
|
|
19
20
|
/** Raw token or magic link (hq://accept/<token>) */
|
|
@@ -78,8 +79,26 @@ export async function accept(options: AcceptOptions): Promise<AcceptResult> {
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
void emitCloudTelemetry(client, {
|
|
83
|
+
eventName: "membership_accept",
|
|
84
|
+
source: "hq-cli",
|
|
85
|
+
companyUid: membership.companyUid,
|
|
86
|
+
properties: {
|
|
87
|
+
role: membership.role,
|
|
88
|
+
result: "success",
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
81
92
|
return { membership, companySlug };
|
|
82
93
|
} catch (err) {
|
|
94
|
+
void emitCloudTelemetry(client, {
|
|
95
|
+
eventName: "membership_accept",
|
|
96
|
+
source: "hq-cli",
|
|
97
|
+
properties: {
|
|
98
|
+
result: "failure",
|
|
99
|
+
errorCode: classifyAcceptError(err),
|
|
100
|
+
},
|
|
101
|
+
});
|
|
83
102
|
if (err instanceof VaultAuthError) {
|
|
84
103
|
throw new Error("Authentication failed — run `hq auth` to refresh your session");
|
|
85
104
|
}
|
|
@@ -95,3 +114,11 @@ export async function accept(options: AcceptOptions): Promise<AcceptResult> {
|
|
|
95
114
|
throw err;
|
|
96
115
|
}
|
|
97
116
|
}
|
|
117
|
+
|
|
118
|
+
function classifyAcceptError(err: unknown): string {
|
|
119
|
+
if (err instanceof VaultAuthError) return "auth";
|
|
120
|
+
if (err instanceof VaultConflictError) return "conflict";
|
|
121
|
+
if (err instanceof VaultNotFoundError) return "not_found";
|
|
122
|
+
if (err instanceof VaultPermissionDeniedError) return "permission";
|
|
123
|
+
return "error";
|
|
124
|
+
}
|
package/src/cli/invite.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
VaultConflictError,
|
|
15
15
|
} from "../vault-client.js";
|
|
16
16
|
import type { MembershipRole, Membership } from "../vault-client.js";
|
|
17
|
+
import { emitCloudTelemetry } from "../telemetry-events.js";
|
|
17
18
|
|
|
18
19
|
export interface InviteOptions {
|
|
19
20
|
/** Target — email address or person slug/uid */
|
|
@@ -83,6 +84,17 @@ export async function invite(options: InviteOptions): Promise<InviteResult> {
|
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
const magicLink = `hq://accept/${result.inviteToken}`;
|
|
87
|
+
void emitCloudTelemetry(client, {
|
|
88
|
+
eventName: "membership_invite",
|
|
89
|
+
source: "hq-cli",
|
|
90
|
+
companyUid,
|
|
91
|
+
properties: {
|
|
92
|
+
role,
|
|
93
|
+
result: "success",
|
|
94
|
+
targetType: isEmail ? "email" : "person",
|
|
95
|
+
allowedPrefixCount: allowedPrefixes?.length ?? 0,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
86
98
|
|
|
87
99
|
return {
|
|
88
100
|
inviteToken: result.inviteToken,
|
|
@@ -90,6 +102,18 @@ export async function invite(options: InviteOptions): Promise<InviteResult> {
|
|
|
90
102
|
membership: result.membership,
|
|
91
103
|
};
|
|
92
104
|
} catch (err) {
|
|
105
|
+
void emitCloudTelemetry(client, {
|
|
106
|
+
eventName: "membership_invite",
|
|
107
|
+
source: "hq-cli",
|
|
108
|
+
companyUid,
|
|
109
|
+
properties: {
|
|
110
|
+
role,
|
|
111
|
+
result: "failure",
|
|
112
|
+
errorCode: classifyInviteError(err),
|
|
113
|
+
targetType: isEmail ? "email" : "person",
|
|
114
|
+
allowedPrefixCount: allowedPrefixes?.length ?? 0,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
93
117
|
if (err instanceof VaultAuthError) {
|
|
94
118
|
throw new Error("Authentication failed — run `hq auth` to refresh your session");
|
|
95
119
|
}
|
|
@@ -103,6 +127,13 @@ export async function invite(options: InviteOptions): Promise<InviteResult> {
|
|
|
103
127
|
}
|
|
104
128
|
}
|
|
105
129
|
|
|
130
|
+
function classifyInviteError(err: unknown): string {
|
|
131
|
+
if (err instanceof VaultAuthError) return "auth";
|
|
132
|
+
if (err instanceof VaultPermissionDeniedError) return "permission";
|
|
133
|
+
if (err instanceof VaultConflictError) return "conflict";
|
|
134
|
+
return "error";
|
|
135
|
+
}
|
|
136
|
+
|
|
106
137
|
/**
|
|
107
138
|
* List pending invites for a company.
|
|
108
139
|
*/
|
package/src/cli/promote.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
VaultConflictError,
|
|
14
14
|
} from "../vault-client.js";
|
|
15
15
|
import type { MembershipRole, Membership } from "../vault-client.js";
|
|
16
|
+
import { emitCloudTelemetry } from "../telemetry-events.js";
|
|
16
17
|
|
|
17
18
|
export interface PromoteOptions {
|
|
18
19
|
/** Person slug or UID of the member to promote */
|
|
@@ -66,8 +67,30 @@ export async function promote(options: PromoteOptions): Promise<PromoteResult> {
|
|
|
66
67
|
companyUid,
|
|
67
68
|
});
|
|
68
69
|
|
|
70
|
+
void emitCloudTelemetry(client, {
|
|
71
|
+
eventName: "membership_promote",
|
|
72
|
+
source: "hq-cli",
|
|
73
|
+
companyUid,
|
|
74
|
+
properties: {
|
|
75
|
+
role: newRole,
|
|
76
|
+
result: "success",
|
|
77
|
+
allowedPrefixCount: allowedPrefixes?.length ?? 0,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
69
81
|
return { membership };
|
|
70
82
|
} catch (err) {
|
|
83
|
+
void emitCloudTelemetry(client, {
|
|
84
|
+
eventName: "membership_promote",
|
|
85
|
+
source: "hq-cli",
|
|
86
|
+
companyUid,
|
|
87
|
+
properties: {
|
|
88
|
+
role: newRole,
|
|
89
|
+
result: "failure",
|
|
90
|
+
errorCode: classifyPromoteError(err),
|
|
91
|
+
allowedPrefixCount: allowedPrefixes?.length ?? 0,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
71
94
|
if (err instanceof VaultAuthError) {
|
|
72
95
|
throw new Error("Authentication failed — run `hq auth` to refresh your session");
|
|
73
96
|
}
|
|
@@ -84,6 +107,14 @@ export async function promote(options: PromoteOptions): Promise<PromoteResult> {
|
|
|
84
107
|
}
|
|
85
108
|
}
|
|
86
109
|
|
|
110
|
+
function classifyPromoteError(err: unknown): string {
|
|
111
|
+
if (err instanceof VaultAuthError) return "auth";
|
|
112
|
+
if (err instanceof VaultPermissionDeniedError) return "permission";
|
|
113
|
+
if (err instanceof VaultNotFoundError) return "not_found";
|
|
114
|
+
if (err instanceof VaultConflictError) return "conflict";
|
|
115
|
+
return "error";
|
|
116
|
+
}
|
|
117
|
+
|
|
87
118
|
// ---------------------------------------------------------------------------
|
|
88
119
|
// Helpers
|
|
89
120
|
// ---------------------------------------------------------------------------
|
package/src/cli/reindex.test.ts
CHANGED
|
@@ -389,17 +389,46 @@ describe("reindex", () => {
|
|
|
389
389
|
expect(fs.readFileSync(path.join(link, "note.md"), "utf-8")).toBe("owned by personal\n");
|
|
390
390
|
});
|
|
391
391
|
|
|
392
|
-
// ── operation lock
|
|
392
|
+
// ── operation lock: reindex uses a SEPARATE scope from sync/rescue ──────
|
|
393
|
+
|
|
394
|
+
// Regression for feedback_ed98d810 (the "hq reindex indefinite hang"): when
|
|
395
|
+
// a watch-mode hq-sync-runner holds the per-root "operation" lock for its
|
|
396
|
+
// whole lifetime, a shared lock starved `hq reindex` forever. reindex now
|
|
397
|
+
// takes its own "reindex" scope, so it must run to completion even while the
|
|
398
|
+
// sync/watch "operation" lock is held by a LIVE holder.
|
|
399
|
+
it("completes while the sync/watch operation lock is held (no starvation)", () => {
|
|
400
|
+
// Simulate the long-lived watch runner holding the "operation"-scope lock.
|
|
401
|
+
// pid = this process → the lock probes it as ALIVE (a live same-PID holder
|
|
402
|
+
// is treated as busy, never reclaimed), so it is a genuine live holder.
|
|
403
|
+
const syncLock = lockPathFor(root); // default "operation" scope
|
|
404
|
+
fs.mkdirSync(path.dirname(syncLock), { recursive: true });
|
|
405
|
+
fs.writeFileSync(
|
|
406
|
+
syncLock,
|
|
407
|
+
JSON.stringify({
|
|
408
|
+
pid: process.pid,
|
|
409
|
+
command: "sync",
|
|
410
|
+
startedAt: new Date(0).toISOString(),
|
|
411
|
+
hqRoot: root,
|
|
412
|
+
}),
|
|
413
|
+
);
|
|
414
|
+
// Even with a zero-wait bound (never block), reindex acquires its OWN scope
|
|
415
|
+
// and finishes — proving the operation lock no longer gates it.
|
|
416
|
+
const { status } = reindex({ repoRoot: root, lockTimeoutSec: 0 });
|
|
417
|
+
expect(status).toBe(0);
|
|
418
|
+
expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(true);
|
|
419
|
+
// The sync/watch holder is untouched.
|
|
420
|
+
expect(fs.existsSync(syncLock)).toBe(true);
|
|
421
|
+
});
|
|
393
422
|
|
|
394
|
-
it("refuses (OPERATION_LOCKED_EXIT) when another
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
// wait-then-acquire path is
|
|
398
|
-
const lp = lockPathFor(root);
|
|
423
|
+
it("refuses (OPERATION_LOCKED_EXIT) when another reindex holds the reindex-scope lock (lockTimeoutSec:0)", () => {
|
|
424
|
+
// reindex-vs-reindex is still mutually exclusive. A live holder on the
|
|
425
|
+
// "reindex" scope → with lockTimeoutSec:0 reindex refuses immediately and
|
|
426
|
+
// does no work. (The wait-then-acquire path is in operation-lock.test.ts.)
|
|
427
|
+
const lp = lockPathFor(root, "reindex");
|
|
399
428
|
fs.mkdirSync(path.dirname(lp), { recursive: true });
|
|
400
429
|
fs.writeFileSync(
|
|
401
430
|
lp,
|
|
402
|
-
JSON.stringify({ pid: 1, command: "
|
|
431
|
+
JSON.stringify({ pid: 1, command: "reindex", startedAt: new Date(0).toISOString(), hqRoot: root }),
|
|
403
432
|
);
|
|
404
433
|
const before = fs.existsSync(path.join(root, ".claude/skills"));
|
|
405
434
|
const { status } = reindex({ repoRoot: root, lockTimeoutSec: 0 });
|
|
@@ -408,22 +437,24 @@ describe("reindex", () => {
|
|
|
408
437
|
expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(before);
|
|
409
438
|
});
|
|
410
439
|
|
|
411
|
-
it("skipLock bypasses the lock (internal sync/rescue caller path)", () => {
|
|
412
|
-
const lp = lockPathFor(root);
|
|
440
|
+
it("skipLock bypasses the reindex lock (internal sync/rescue caller path)", () => {
|
|
441
|
+
const lp = lockPathFor(root, "reindex");
|
|
413
442
|
fs.mkdirSync(path.dirname(lp), { recursive: true });
|
|
414
443
|
fs.writeFileSync(
|
|
415
444
|
lp,
|
|
416
|
-
JSON.stringify({ pid: 1, command: "
|
|
445
|
+
JSON.stringify({ pid: 1, command: "reindex", startedAt: new Date(0).toISOString(), hqRoot: root }),
|
|
417
446
|
);
|
|
418
|
-
// Even with a live holder on record, the internal caller (which
|
|
419
|
-
//
|
|
447
|
+
// Even with a live reindex holder on record, the internal caller (which
|
|
448
|
+
// serializes reindex itself at the tail of a pass) runs to completion.
|
|
420
449
|
const { status } = reindex({ repoRoot: root, skipLock: true });
|
|
421
450
|
expect(status).toBe(0);
|
|
422
451
|
expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(true);
|
|
423
452
|
});
|
|
424
453
|
|
|
425
|
-
it("releases the lock after a normal run (no leftover lock file)", () => {
|
|
454
|
+
it("releases the reindex-scope lock after a normal run (no leftover lock file)", () => {
|
|
426
455
|
reindex({ repoRoot: root });
|
|
456
|
+
expect(fs.existsSync(lockPathFor(root, "reindex"))).toBe(false);
|
|
457
|
+
// ...and never created the sync/rescue "operation" lock.
|
|
427
458
|
expect(fs.existsSync(lockPathFor(root))).toBe(false);
|
|
428
459
|
});
|
|
429
460
|
|
|
@@ -526,4 +557,73 @@ describe("reindex", () => {
|
|
|
526
557
|
fs.lstatSync(path.join(root, ".claude/skills/core:demo/SKILL.md")).isSymbolicLink(),
|
|
527
558
|
).toBe(true);
|
|
528
559
|
});
|
|
560
|
+
|
|
561
|
+
// --- session-log capture into workspace/.session-logs ---------------------
|
|
562
|
+
describe("session-log capture", () => {
|
|
563
|
+
let claudeConfigDir: string;
|
|
564
|
+
// Claude Code encodes the project cwd into its projects-dir name by
|
|
565
|
+
// replacing every non-alphanumeric char with '-'; mirror that here.
|
|
566
|
+
const slugFor = (p: string): string => p.replace(/[^a-zA-Z0-9]/g, "-");
|
|
567
|
+
|
|
568
|
+
beforeEach(() => {
|
|
569
|
+
claudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "ms-claude-"));
|
|
570
|
+
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir;
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
afterEach(() => {
|
|
574
|
+
fs.rmSync(claudeConfigDir, { recursive: true, force: true });
|
|
575
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
function seedProjectLogs(): string {
|
|
579
|
+
const projDir = path.join(claudeConfigDir, "projects", slugFor(root));
|
|
580
|
+
fs.mkdirSync(path.join(projDir, "tool-results"), { recursive: true });
|
|
581
|
+
fs.writeFileSync(path.join(projDir, "session.jsonl"), "line-1\n");
|
|
582
|
+
fs.writeFileSync(path.join(projDir, "tool-results", "r.txt"), "result\n");
|
|
583
|
+
return projDir;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
it("copies all files and folders from the projects dir into workspace/.session-logs", () => {
|
|
587
|
+
seedProjectLogs();
|
|
588
|
+
|
|
589
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
590
|
+
|
|
591
|
+
const dest = path.join(root, "workspace", ".session-logs");
|
|
592
|
+
expect(fs.readFileSync(path.join(dest, "session.jsonl"), "utf8")).toBe("line-1\n");
|
|
593
|
+
expect(fs.readFileSync(path.join(dest, "tool-results", "r.txt"), "utf8")).toBe("result\n");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("overwrites an existing dest file with the newer source copy", () => {
|
|
597
|
+
seedProjectLogs();
|
|
598
|
+
const dest = path.join(root, "workspace", ".session-logs");
|
|
599
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
600
|
+
fs.writeFileSync(path.join(dest, "session.jsonl"), "STALE\n");
|
|
601
|
+
|
|
602
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
603
|
+
|
|
604
|
+
expect(fs.readFileSync(path.join(dest, "session.jsonl"), "utf8")).toBe("line-1\n");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("never deletes dest entries that have no source counterpart", () => {
|
|
608
|
+
seedProjectLogs();
|
|
609
|
+
const dest = path.join(root, "workspace", ".session-logs");
|
|
610
|
+
fs.mkdirSync(path.join(dest, "old-run"), { recursive: true });
|
|
611
|
+
fs.writeFileSync(path.join(dest, "old-run", "keep.jsonl"), "keep\n");
|
|
612
|
+
fs.writeFileSync(path.join(dest, "orphan.txt"), "orphan\n");
|
|
613
|
+
|
|
614
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
615
|
+
|
|
616
|
+
// Source content is present...
|
|
617
|
+
expect(fs.existsSync(path.join(dest, "session.jsonl"))).toBe(true);
|
|
618
|
+
// ...and the dest-only entries survived (no prune).
|
|
619
|
+
expect(fs.readFileSync(path.join(dest, "old-run", "keep.jsonl"), "utf8")).toBe("keep\n");
|
|
620
|
+
expect(fs.readFileSync(path.join(dest, "orphan.txt"), "utf8")).toBe("orphan\n");
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("no-ops (still exit 0) when there is no projects dir for this root", () => {
|
|
624
|
+
// No seedProjectLogs() → the source dir doesn't exist.
|
|
625
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
626
|
+
expect(fs.existsSync(path.join(root, "workspace", ".session-logs"))).toBe(false);
|
|
627
|
+
});
|
|
628
|
+
});
|
|
529
629
|
});
|
package/src/cli/reindex.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* hq reindex — surfaces namespaced skills as Claude Code skill wrappers under
|
|
3
3
|
* .claude/skills/<ns>:<skill>/ (one symlink per source file), mirrors
|
|
4
4
|
* personal/{knowledge,policies,workers,settings}/<entry> into core/<type>/,
|
|
5
|
-
* prunes orphan wrappers + legacy command symlinks,
|
|
6
|
-
* workers
|
|
5
|
+
* prunes orphan wrappers + legacy command symlinks, captures this root's Claude
|
|
6
|
+
* Code session logs into workspace/.session-logs, and regenerates the workers
|
|
7
|
+
* registry.
|
|
7
8
|
*
|
|
8
9
|
* The logic historically lived in a bundled bash script (scripts/reindex.sh)
|
|
9
10
|
* that this module exec'd via `bash`. It is now implemented natively in
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
*/
|
|
17
18
|
import { spawnSync } from "child_process";
|
|
18
19
|
import * as fs from "fs";
|
|
20
|
+
import * as os from "os";
|
|
19
21
|
import * as path from "path";
|
|
20
22
|
import {
|
|
21
23
|
acquireOperationLock,
|
|
@@ -25,29 +27,45 @@ import {
|
|
|
25
27
|
OPERATION_LOCK_UNWRITABLE_EXIT,
|
|
26
28
|
type LockHandle,
|
|
27
29
|
} from "../operation-lock.js";
|
|
30
|
+
import {
|
|
31
|
+
emitCloudTelemetry,
|
|
32
|
+
type CloudTelemetryClient,
|
|
33
|
+
type TelemetryClaims,
|
|
34
|
+
} from "../telemetry-events.js";
|
|
28
35
|
|
|
29
36
|
export interface ReindexOptions {
|
|
30
37
|
/** HQ root to operate on. Defaults to process.cwd(). */
|
|
31
38
|
repoRoot?: string;
|
|
32
39
|
/**
|
|
33
|
-
* Skip
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
40
|
+
* Skip reindex's own per-root "reindex"-scope lock. Internal callers
|
|
41
|
+
* (`sync()` / `rescue()`) run reindex inline at the tail of a pass they
|
|
42
|
+
* already serialize, and pass `true` so a standalone `hq reindex` holding the
|
|
43
|
+
* reindex lock never blocks the sync/rescue tail (reindex is idempotent and
|
|
44
|
+
* re-run every pass, so a benign overlap self-heals). Standalone callers
|
|
45
|
+
* (`hq reindex`, the reindex hook) leave it falsy so concurrent reindexes are
|
|
46
|
+
* mutually exclusive.
|
|
38
47
|
*/
|
|
39
48
|
skipLock?: boolean;
|
|
40
49
|
/**
|
|
41
|
-
* Bounded wait (seconds) for the per-root
|
|
42
|
-
* is already running. `0` → refuse immediately; omitted → inherit
|
|
50
|
+
* Bounded wait (seconds) for the per-root "reindex"-scope lock when another
|
|
51
|
+
* reindex is already running. `0` → refuse immediately; omitted → inherit
|
|
43
52
|
* `HQ_OP_LOCK_TIMEOUT` / infinite wait. Ignored when `skipLock` is set.
|
|
44
53
|
*
|
|
45
|
-
* NB:
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
54
|
+
* NB: reindex uses a lock scope SEPARATE from sync/rescue's "operation" lock,
|
|
55
|
+
* so a long-lived watch-mode sync-runner (which holds the operation lock for
|
|
56
|
+
* its whole lifetime) can never starve a standalone `hq reindex`
|
|
57
|
+
* (feedback_ed98d810). Standalone `hq reindex` waits by default, which is what
|
|
58
|
+
* a human running it wants; the hq-core reindex HOOK (Stop / PostToolUse)
|
|
59
|
+
* should set a small `HQ_OP_LOCK_TIMEOUT` (or `0`) so a hook fired while
|
|
60
|
+
* another reindex runs never blocks the interactive agent indefinitely.
|
|
49
61
|
*/
|
|
50
62
|
lockTimeoutSec?: number;
|
|
63
|
+
/** Optional hq-pro ACTION telemetry sink. Omitted in tests/standalone hooks. */
|
|
64
|
+
telemetryClient?: CloudTelemetryClient | null;
|
|
65
|
+
telemetryClaims?: TelemetryClaims | null;
|
|
66
|
+
telemetrySessionId?: string;
|
|
67
|
+
telemetryCompanyUid?: string;
|
|
68
|
+
changedCount?: number;
|
|
51
69
|
}
|
|
52
70
|
|
|
53
71
|
export interface ReindexResult {
|
|
@@ -181,6 +199,46 @@ function safeSymlink(target: string, linkPath: string, label: string): boolean {
|
|
|
181
199
|
}
|
|
182
200
|
}
|
|
183
201
|
|
|
202
|
+
// Copy every file/folder under Claude Code's session-log directory for this HQ
|
|
203
|
+
// root (`<claude-config>/projects/<encoded-root>`) into
|
|
204
|
+
// `workspace/.session-logs`, so the raw session transcripts are captured inside
|
|
205
|
+
// the HQ tree (where sync/search can see them) on each reindex.
|
|
206
|
+
//
|
|
207
|
+
// Contract (per the feature request):
|
|
208
|
+
// - copies ALL files and folders from the projects dir into the dest,
|
|
209
|
+
// - OVERWRITES existing dest files (force), and
|
|
210
|
+
// - NEVER deletes dest entries that lack a source counterpart — this is a
|
|
211
|
+
// one-way overlay, not a mirror, so anything already parked in
|
|
212
|
+
// workspace/.session-logs survives even after Claude Code rotates or prunes
|
|
213
|
+
// its own projects dir.
|
|
214
|
+
//
|
|
215
|
+
// Best-effort: any failure warns to stderr and is swallowed so it can never
|
|
216
|
+
// fail the reindex. `<claude-config>` honors CLAUDE_CONFIG_DIR, else ~/.claude.
|
|
217
|
+
function copySessionLogs(root: string): void {
|
|
218
|
+
// Claude Code names a project's log dir by replacing every non-alphanumeric
|
|
219
|
+
// character in the project's cwd with '-' (e.g. /home/ec2-user/hq ->
|
|
220
|
+
// -home-ec2-user-hq). Re-derive that slug so we target THIS root's logs.
|
|
221
|
+
const slug = root.replace(/[^a-zA-Z0-9]/g, "-");
|
|
222
|
+
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
223
|
+
const src = path.join(claudeConfigDir, "projects", slug);
|
|
224
|
+
if (!isDir(src)) return; // no session logs for this root yet — nothing to do
|
|
225
|
+
|
|
226
|
+
const dest = path.join(root, "workspace", ".session-logs");
|
|
227
|
+
if (!safeMkdir(dest, "workspace/.session-logs directory")) return;
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
// recursive: copy the whole tree; force: overwrite existing dest files.
|
|
231
|
+
// cpSync copies src INTO dest and never removes dest-only entries — exactly
|
|
232
|
+
// the "overwrite, but don't delete extras" contract we want.
|
|
233
|
+
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
234
|
+
} catch (err) {
|
|
235
|
+
warn(
|
|
236
|
+
`reindex: could not copy session logs from '${src}' to '${dest}' ` +
|
|
237
|
+
`(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
184
242
|
// --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
|
|
185
243
|
// Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
|
|
186
244
|
function isLegacyCommandTarget(t: string): boolean {
|
|
@@ -202,23 +260,32 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
202
260
|
const rawRoot = opts.repoRoot ?? process.cwd();
|
|
203
261
|
if (!isDir(rawRoot)) {
|
|
204
262
|
warn(`reindex: REPO_ROOT '${rawRoot}' is not a directory`);
|
|
205
|
-
|
|
263
|
+
const result = { status: 1 };
|
|
264
|
+
emitReindexTelemetry(opts, result, "not_attempted");
|
|
265
|
+
return result;
|
|
206
266
|
}
|
|
207
267
|
const root = path.resolve(rawRoot);
|
|
208
268
|
|
|
209
|
-
// Acquire the per-root
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
269
|
+
// Acquire the per-root "reindex"-scope lock unless an internal caller
|
|
270
|
+
// (sync/rescue) opted out. This scope is SEPARATE from sync/rescue's
|
|
271
|
+
// "operation" lock, so a long-lived watch-mode sync-runner holding the
|
|
272
|
+
// operation lock never starves us (feedback_ed98d810) — we only contend with
|
|
273
|
+
// other reindexes. A live holder → refuse fast with the holder's command +
|
|
274
|
+
// PID. The whole body runs inside the try so the lock is released on every
|
|
275
|
+
// exit path (the process-level signal/exit hooks are the crash backstop).
|
|
214
276
|
let opLock: LockHandle | null = null;
|
|
215
277
|
if (!opts.skipLock) {
|
|
216
278
|
try {
|
|
217
|
-
opLock = acquireOperationLock(root, "reindex", {
|
|
279
|
+
opLock = acquireOperationLock(root, "reindex", {
|
|
280
|
+
scope: "reindex",
|
|
281
|
+
timeoutSec: opts.lockTimeoutSec,
|
|
282
|
+
});
|
|
218
283
|
} catch (err) {
|
|
219
284
|
if (err instanceof OperationLockedError) {
|
|
220
285
|
warn(err.message);
|
|
221
|
-
|
|
286
|
+
const result = { status: OPERATION_LOCKED_EXIT };
|
|
287
|
+
emitReindexTelemetry(opts, result, "locked");
|
|
288
|
+
return result;
|
|
222
289
|
}
|
|
223
290
|
if (err instanceof OperationLockUnwritableError) {
|
|
224
291
|
// The lock dir isn't writable (e.g. macOS EPERM / Linux EACCES on a
|
|
@@ -227,7 +294,9 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
227
294
|
// and exit cleanly instead of crashing with a raw `EPERM ... open`
|
|
228
295
|
// (HQ-CLI-2).
|
|
229
296
|
warn(err.message);
|
|
230
|
-
|
|
297
|
+
const result = { status: OPERATION_LOCK_UNWRITABLE_EXIT };
|
|
298
|
+
emitReindexTelemetry(opts, result, "unwritable");
|
|
299
|
+
return result;
|
|
231
300
|
}
|
|
232
301
|
throw err;
|
|
233
302
|
}
|
|
@@ -522,6 +591,11 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
522
591
|
}
|
|
523
592
|
}
|
|
524
593
|
|
|
594
|
+
// --- Session-log capture --------------------------------------------------
|
|
595
|
+
// Overlay this root's Claude Code session logs into workspace/.session-logs
|
|
596
|
+
// (copy-all, overwrite, never-prune). Best-effort; never fails the reindex.
|
|
597
|
+
copySessionLogs(root);
|
|
598
|
+
|
|
525
599
|
// --- Workers registry regeneration ----------------------------------------
|
|
526
600
|
// Source of truth: each worker.yaml. The registry is a derived index — the
|
|
527
601
|
// generator (when present in the operated-on tree) keeps it in sync.
|
|
@@ -548,8 +622,34 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
548
622
|
);
|
|
549
623
|
}
|
|
550
624
|
|
|
551
|
-
|
|
625
|
+
const result = { status: 0 };
|
|
626
|
+
emitReindexTelemetry(opts, result, opts.skipLock ? "skipped" : "acquired");
|
|
627
|
+
return result;
|
|
552
628
|
} finally {
|
|
553
629
|
opLock?.release();
|
|
554
630
|
}
|
|
555
631
|
}
|
|
632
|
+
|
|
633
|
+
function emitReindexTelemetry(
|
|
634
|
+
opts: ReindexOptions,
|
|
635
|
+
result: ReindexResult,
|
|
636
|
+
lockResult: string,
|
|
637
|
+
): void {
|
|
638
|
+
void emitCloudTelemetry(
|
|
639
|
+
opts.telemetryClient,
|
|
640
|
+
{
|
|
641
|
+
eventName: "reindex_run",
|
|
642
|
+
source: "hq-reindex",
|
|
643
|
+
...(opts.telemetryCompanyUid ? { companyUid: opts.telemetryCompanyUid } : {}),
|
|
644
|
+
...(opts.telemetrySessionId ? { sessionId: opts.telemetrySessionId } : {}),
|
|
645
|
+
properties: {
|
|
646
|
+
result: result.status === 0 ? "completed" : "failed",
|
|
647
|
+
statusCode: result.status,
|
|
648
|
+
lockResult,
|
|
649
|
+
changedCount: opts.changedCount ?? 0,
|
|
650
|
+
skipLockState: opts.skipLock === true ? "skipped" : "acquired",
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
{ claims: opts.telemetryClaims },
|
|
654
|
+
);
|
|
655
|
+
}
|