@indigoai-us/hq-cli 5.119.13 → 5.120.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 +26 -0
- package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +36 -3
- package/dist/command-catalog.generated.d.ts +9 -0
- package/dist/command-catalog.generated.js +12 -0
- package/dist/commands/agents.d.ts +18 -0
- package/dist/commands/agents.js +59 -0
- package/dist/commands/core.js +13 -1
- package/dist/lib/index-render/company-knowledge.js +80 -17
- package/dist/lib/index-render/shared.d.ts +12 -0
- package/dist/lib/index-render/shared.js +37 -0
- package/dist/utils/people.d.ts +4 -11
- package/dist/utils/people.js +5 -20
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.120.0] — 2026-09-18
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq agents provision` can reuse an existing Slack bot token through flags,
|
|
10
|
+
environment variables, or stdin. Socket Mode agents can also accept their
|
|
11
|
+
app-level token. HQ validates both tokens and the bot's required Slack scopes
|
|
12
|
+
before it creates the agent.
|
|
13
|
+
|
|
14
|
+
## [5.119.15] — 2026-09-18
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `hq people resolve <name>` now reports ambiguity when the name matches more
|
|
19
|
+
than one person instead of selecting the first match.
|
|
20
|
+
|
|
5
21
|
## [5.119.13] — 2026-09-18
|
|
6
22
|
|
|
7
23
|
### Added
|
|
@@ -11,6 +27,16 @@
|
|
|
11
27
|
out it says to run `/update-hq`; if the lookup cannot reach the network it
|
|
12
28
|
says so instead of going quiet. `hq version --json` prints the same payload.
|
|
13
29
|
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- `hq core rebuild-index company-knowledge` no longer silently replaces a
|
|
33
|
+
hand-written company knowledge INDEX. If the file is not the last generated
|
|
34
|
+
output, the command refuses, prints a short diff, and asks for `--accept`.
|
|
35
|
+
`<!-- hq:keep -->` opts the whole file out; `<!-- hq:keep -->` … `<!-- /hq:keep -->`
|
|
36
|
+
fences are kept while the table regenerates. Subdirectory README headings
|
|
37
|
+
are projected into the description column so the generated index is usable
|
|
38
|
+
without hand edits.
|
|
39
|
+
|
|
14
40
|
## [5.119.12] — 2026-09-18
|
|
15
41
|
|
|
16
42
|
### Fixed
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
# - .md files → first `#` heading (stripped)
|
|
9
9
|
# - .yaml files → `description:` field if present
|
|
10
10
|
# - .json files → `description` field if present
|
|
11
|
-
# - directories → "{N} item(s)"
|
|
11
|
+
# - directories → README.md `#` heading plus "{N} item(s)", or "{N} item(s)"
|
|
12
12
|
#
|
|
13
13
|
# Most company knowledge dirs are embedded git repos (160000 gitlinks). The
|
|
14
14
|
# generated INDEX.md lives inside the inner repo; HQ git won't track its
|
|
@@ -42,7 +42,15 @@ describe_item() {
|
|
|
42
42
|
if [[ -d "$path" ]]; then
|
|
43
43
|
local n
|
|
44
44
|
n=$(find "$path" -mindepth 1 -maxdepth 1 ! -name '.*' 2>/dev/null | wc -l | tr -d ' ')
|
|
45
|
-
|
|
45
|
+
local h=""
|
|
46
|
+
if [[ -f "$path/README.md" ]]; then
|
|
47
|
+
h=$(awk '/^# / { sub(/^# +/, ""); print; exit }' "$path/README.md" 2>/dev/null || true)
|
|
48
|
+
fi
|
|
49
|
+
if [[ -n "$h" ]]; then
|
|
50
|
+
echo "${h} (${n} item(s))"
|
|
51
|
+
else
|
|
52
|
+
echo "${n} item(s)"
|
|
53
|
+
fi
|
|
46
54
|
return
|
|
47
55
|
fi
|
|
48
56
|
case "$name" in
|
|
@@ -70,6 +78,26 @@ describe_item() {
|
|
|
70
78
|
esac
|
|
71
79
|
}
|
|
72
80
|
|
|
81
|
+
sha256_file() {
|
|
82
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
83
|
+
sha256sum "$1" | awk '{print $1}'
|
|
84
|
+
else
|
|
85
|
+
shasum -a 256 "$1" | awk '{print $1}'
|
|
86
|
+
fi
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
stamp_generated() {
|
|
90
|
+
local tmp="$1"
|
|
91
|
+
local out="$2"
|
|
92
|
+
local norm
|
|
93
|
+
norm=$(mktemp)
|
|
94
|
+
sed 's/^> Auto-generated. Updated: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/> Auto-generated. Updated: <date>/' "$tmp" > "$norm"
|
|
95
|
+
local hash
|
|
96
|
+
hash=$(sha256_file "$norm")
|
|
97
|
+
rm -f "$norm"
|
|
98
|
+
awk -v h="$hash" '{print} /^> Auto-generated. Updated: / {print "<!-- hq-generated-sha256: " h " -->"}' "$tmp" > "$out"
|
|
99
|
+
}
|
|
100
|
+
|
|
73
101
|
write_knowledge_index() {
|
|
74
102
|
local co="$1"
|
|
75
103
|
local kdir="companies/${co}/knowledge"
|
|
@@ -77,6 +105,8 @@ write_knowledge_index() {
|
|
|
77
105
|
local out="${kdir}/INDEX.md"
|
|
78
106
|
local title
|
|
79
107
|
title="$(titleize "$co") Knowledge"
|
|
108
|
+
local tmp
|
|
109
|
+
tmp=$(mktemp)
|
|
80
110
|
|
|
81
111
|
{
|
|
82
112
|
echo "# ${title}"
|
|
@@ -108,7 +138,10 @@ write_knowledge_index() {
|
|
|
108
138
|
find -L "$kdir" -mindepth 1 -maxdepth 1 -type f 2>/dev/null | sort
|
|
109
139
|
}
|
|
110
140
|
)
|
|
111
|
-
} > "$
|
|
141
|
+
} > "$tmp"
|
|
142
|
+
|
|
143
|
+
stamp_generated "$tmp" "$out"
|
|
144
|
+
rm -f "$tmp"
|
|
112
145
|
|
|
113
146
|
echo "rebuild-company-knowledge-index: wrote ${out}" >&2
|
|
114
147
|
}
|
|
@@ -3887,6 +3887,15 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
3887
3887
|
}, {
|
|
3888
3888
|
readonly flags: "--api-key-env <VAR>";
|
|
3889
3889
|
readonly description: "Env var holding the API key for --auth-mode apiKey (never pass the key as a flag)";
|
|
3890
|
+
}, {
|
|
3891
|
+
readonly flags: "--slack-bot-token <xoxb-token>";
|
|
3892
|
+
readonly description: "Reuse an existing Slack bot token; HQ_SLACK_BOT_TOKEN or --slack-tokens-stdin avoid shell history";
|
|
3893
|
+
}, {
|
|
3894
|
+
readonly flags: "--slack-app-token <xapp-token>";
|
|
3895
|
+
readonly description: "Socket Mode app token for --provider agents-v2; HQ_SLACK_APP_TOKEN or --slack-tokens-stdin avoid shell history";
|
|
3896
|
+
}, {
|
|
3897
|
+
readonly flags: "--slack-tokens-stdin";
|
|
3898
|
+
readonly description: "Read the bot token and optional app token from one or two stdin lines";
|
|
3890
3899
|
}, {
|
|
3891
3900
|
readonly flags: "--title <title>";
|
|
3892
3901
|
readonly description: "Org-chart job title";
|
|
@@ -5025,6 +5025,18 @@ export const COMMAND_CATALOG = [
|
|
|
5025
5025
|
"flags": "--api-key-env <VAR>",
|
|
5026
5026
|
"description": "Env var holding the API key for --auth-mode apiKey (never pass the key as a flag)"
|
|
5027
5027
|
},
|
|
5028
|
+
{
|
|
5029
|
+
"flags": "--slack-bot-token <xoxb-token>",
|
|
5030
|
+
"description": "Reuse an existing Slack bot token; HQ_SLACK_BOT_TOKEN or --slack-tokens-stdin avoid shell history"
|
|
5031
|
+
},
|
|
5032
|
+
{
|
|
5033
|
+
"flags": "--slack-app-token <xapp-token>",
|
|
5034
|
+
"description": "Socket Mode app token for --provider agents-v2; HQ_SLACK_APP_TOKEN or --slack-tokens-stdin avoid shell history"
|
|
5035
|
+
},
|
|
5036
|
+
{
|
|
5037
|
+
"flags": "--slack-tokens-stdin",
|
|
5038
|
+
"description": "Read the bot token and optional app token from one or two stdin lines"
|
|
5039
|
+
},
|
|
5028
5040
|
{
|
|
5029
5041
|
"flags": "--title <title>",
|
|
5030
5042
|
"description": "Org-chart job title"
|
|
@@ -173,6 +173,9 @@ export interface ProvisionAgentInput {
|
|
|
173
173
|
provider?: "codex" | "grok" | "claude" | "agents-v2";
|
|
174
174
|
codexModel?: string;
|
|
175
175
|
codexApiKey?: string;
|
|
176
|
+
/** Write-only Slack credentials, validated by hq-pro before creation. */
|
|
177
|
+
slackBotToken?: string;
|
|
178
|
+
slackAppToken?: string;
|
|
176
179
|
idempotencyKey: string;
|
|
177
180
|
title?: string;
|
|
178
181
|
description?: string;
|
|
@@ -184,6 +187,21 @@ export interface ProvisionAgentInput {
|
|
|
184
187
|
/** Funnel attribution: which client surface made the attempt. */
|
|
185
188
|
surface?: AgentCreateSurface;
|
|
186
189
|
}
|
|
190
|
+
export interface ProvisionSlackTokenOptions {
|
|
191
|
+
slackBotToken?: string;
|
|
192
|
+
slackAppToken?: string;
|
|
193
|
+
slackTokensStdin?: boolean;
|
|
194
|
+
}
|
|
195
|
+
export interface ProvisionSlackTokens {
|
|
196
|
+
botToken: string;
|
|
197
|
+
appToken?: string;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Resolve write-only Slack credentials without printing them. Flags are useful
|
|
201
|
+
* for automation; HQ_SLACK_BOT_TOKEN/HQ_SLACK_APP_TOKEN and stdin avoid shell
|
|
202
|
+
* history. Multiple sources are rejected so the selected credential is clear.
|
|
203
|
+
*/
|
|
204
|
+
export declare function resolveProvisionSlackTokens(options: ProvisionSlackTokenOptions, environment?: Record<string, string | undefined>, readStdin?: () => Promise<string>): Promise<ProvisionSlackTokens | undefined>;
|
|
187
205
|
/** Closed set shared with hq-pro's agent_create_* funnel contract. */
|
|
188
206
|
export declare const CLI_AGENT_CREATE_SURFACE: "cli_agents_create";
|
|
189
207
|
export type AgentCreateSurface = typeof CLI_AGENT_CREATE_SURFACE;
|
package/dist/commands/agents.js
CHANGED
|
@@ -242,6 +242,51 @@ export function slugifyAgentName(name) {
|
|
|
242
242
|
.replace(/[^a-z0-9]+/g, "-")
|
|
243
243
|
.replace(/^-+|-+$/g, "");
|
|
244
244
|
}
|
|
245
|
+
async function readProvisionSlackTokensFromStdin() {
|
|
246
|
+
let value = "";
|
|
247
|
+
for await (const chunk of process.stdin)
|
|
248
|
+
value += String(chunk);
|
|
249
|
+
return value;
|
|
250
|
+
}
|
|
251
|
+
function parseProvisionSlackTokensFromStdin(value) {
|
|
252
|
+
const lines = value
|
|
253
|
+
.split(/\r?\n/)
|
|
254
|
+
.map((line) => line.trim())
|
|
255
|
+
.filter(Boolean);
|
|
256
|
+
if (lines.length < 1 || lines.length > 2) {
|
|
257
|
+
throw new Error("--slack-tokens-stdin expects one line with the bot token and an optional second line with the Socket Mode app token.");
|
|
258
|
+
}
|
|
259
|
+
return { botToken: lines[0], ...(lines[1] ? { appToken: lines[1] } : {}) };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Resolve write-only Slack credentials without printing them. Flags are useful
|
|
263
|
+
* for automation; HQ_SLACK_BOT_TOKEN/HQ_SLACK_APP_TOKEN and stdin avoid shell
|
|
264
|
+
* history. Multiple sources are rejected so the selected credential is clear.
|
|
265
|
+
*/
|
|
266
|
+
export async function resolveProvisionSlackTokens(options, environment = process.env, readStdin = readProvisionSlackTokensFromStdin) {
|
|
267
|
+
const optionBotToken = options.slackBotToken?.trim();
|
|
268
|
+
const optionAppToken = options.slackAppToken?.trim();
|
|
269
|
+
const environmentBotToken = environment.HQ_SLACK_BOT_TOKEN?.trim();
|
|
270
|
+
const environmentAppToken = environment.HQ_SLACK_APP_TOKEN?.trim();
|
|
271
|
+
const hasFlagOrEnvironment = Boolean(optionBotToken || optionAppToken || environmentBotToken || environmentAppToken);
|
|
272
|
+
if (options.slackTokensStdin) {
|
|
273
|
+
if (hasFlagOrEnvironment) {
|
|
274
|
+
throw new Error("Use --slack-tokens-stdin or the Slack token flags/environment variables, not both.");
|
|
275
|
+
}
|
|
276
|
+
return parseProvisionSlackTokensFromStdin(await readStdin());
|
|
277
|
+
}
|
|
278
|
+
if ((optionBotToken || optionAppToken) && (environmentBotToken || environmentAppToken)) {
|
|
279
|
+
throw new Error("Use Slack token flags or HQ_SLACK_BOT_TOKEN/HQ_SLACK_APP_TOKEN, not both.");
|
|
280
|
+
}
|
|
281
|
+
const botToken = optionBotToken || environmentBotToken;
|
|
282
|
+
const appToken = optionAppToken || environmentAppToken;
|
|
283
|
+
if (!botToken && !appToken)
|
|
284
|
+
return undefined;
|
|
285
|
+
if (!botToken) {
|
|
286
|
+
throw new Error("A Slack app token requires a Slack bot token.");
|
|
287
|
+
}
|
|
288
|
+
return { botToken, ...(appToken ? { appToken } : {}) };
|
|
289
|
+
}
|
|
245
290
|
/** Closed set shared with hq-pro's agent_create_* funnel contract. */
|
|
246
291
|
export const CLI_AGENT_CREATE_SURFACE = "cli_agents_create";
|
|
247
292
|
/** Read hq-pro's company-specific creation prices and capacities. */
|
|
@@ -1132,6 +1177,9 @@ export function registerAgentsCommand(program) {
|
|
|
1132
1177
|
.option("--model <model>", "Brain model, for example gpt-5.5 or grok-4.6")
|
|
1133
1178
|
.option("--auth-mode <mode>", "Auth: subscription | apiKey (default subscription)", "subscription")
|
|
1134
1179
|
.option("--api-key-env <VAR>", "Env var holding the API key for --auth-mode apiKey (never pass the key as a flag)")
|
|
1180
|
+
.option("--slack-bot-token <xoxb-token>", "Reuse an existing Slack bot token; HQ_SLACK_BOT_TOKEN or --slack-tokens-stdin avoid shell history")
|
|
1181
|
+
.option("--slack-app-token <xapp-token>", "Socket Mode app token for --provider agents-v2; HQ_SLACK_APP_TOKEN or --slack-tokens-stdin avoid shell history")
|
|
1182
|
+
.option("--slack-tokens-stdin", "Read the bot token and optional app token from one or two stdin lines")
|
|
1135
1183
|
.option("--title <title>", "Org-chart job title")
|
|
1136
1184
|
.option("--description <text>", "Short description / bio")
|
|
1137
1185
|
.option("--size <size>", "Agent box size: basic | power | dev (omitted keeps the current default)")
|
|
@@ -1150,6 +1198,13 @@ export function registerAgentsCommand(program) {
|
|
|
1150
1198
|
process.exit(1);
|
|
1151
1199
|
}
|
|
1152
1200
|
const provider = explicitProvider ?? (opts.model ? "agents-v2" : undefined);
|
|
1201
|
+
const slackTokens = await resolveProvisionSlackTokens(opts);
|
|
1202
|
+
if (slackTokens?.appToken && provider !== "agents-v2") {
|
|
1203
|
+
throw new Error("A Slack app token can only be supplied with --provider agents-v2 for Socket Mode.");
|
|
1204
|
+
}
|
|
1205
|
+
if (slackTokens && provider === "agents-v2" && !slackTokens.appToken) {
|
|
1206
|
+
throw new Error("--provider agents-v2 requires a Slack app token when reusing a Slack bot token.");
|
|
1207
|
+
}
|
|
1153
1208
|
// claude is subscription-only on hq-pro (rejectIncompatibleProviderAuthMode
|
|
1154
1209
|
// returns AGENT_PROVIDER_INCOMPATIBLE_WITH_AUTH_MODE). Catch it here so the
|
|
1155
1210
|
// operator gets a direct message instead of a 400 from the control plane
|
|
@@ -1202,6 +1257,10 @@ export function registerAgentsCommand(program) {
|
|
|
1202
1257
|
...(provider ? { provider } : {}),
|
|
1203
1258
|
...(opts.model ? { codexModel: opts.model } : {}),
|
|
1204
1259
|
...(codexApiKey ? { codexApiKey } : {}),
|
|
1260
|
+
...(slackTokens ? { slackBotToken: slackTokens.botToken } : {}),
|
|
1261
|
+
...(slackTokens?.appToken
|
|
1262
|
+
? { slackAppToken: slackTokens.appToken }
|
|
1263
|
+
: {}),
|
|
1205
1264
|
idempotencyKey,
|
|
1206
1265
|
...(opts.title ? { title: opts.title } : {}),
|
|
1207
1266
|
...(opts.description ? { description: opts.description } : {}),
|
package/dist/commands/core.js
CHANGED
|
@@ -408,7 +408,19 @@ export function registerCoreCommands(program) {
|
|
|
408
408
|
const scope = core.opts();
|
|
409
409
|
const hqRoot = resolveLiveRoot({ hqRoot: scope.hqRoot });
|
|
410
410
|
const operands = cmd.args.length > 0 ? cmd.args : args;
|
|
411
|
-
|
|
411
|
+
const passthrough = operands.slice(1);
|
|
412
|
+
const force = passthrough.includes("--accept");
|
|
413
|
+
const rendererArgs = passthrough.filter((arg) => arg !== "--accept");
|
|
414
|
+
const refused = [];
|
|
415
|
+
renderIndexTarget(entry.renderer, {
|
|
416
|
+
root: hqRoot,
|
|
417
|
+
log: (message) => process.stderr.write(`${message}\n`),
|
|
418
|
+
force,
|
|
419
|
+
refused,
|
|
420
|
+
}, rendererArgs);
|
|
421
|
+
if (refused.length > 0) {
|
|
422
|
+
throw expectedUserError(`rebuild-index: refused to overwrite ${refused.length} hand-edited INDEX.md file(s). Re-run with --accept to overwrite.`);
|
|
423
|
+
}
|
|
412
424
|
});
|
|
413
425
|
for (const entry of SCAFFOLD_COMMANDS) {
|
|
414
426
|
core
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import { at, basename, date, extractKeepFences, generatedFingerprint, generatedStamp, hasUnclosedKeepMarker, heading, immediateEntries, isHidden, log, looksAutoGenerated, readJson, readText, sanitize, stampGenerated, titleize, truncate, write, } from "./shared.js";
|
|
2
3
|
function describe(item) {
|
|
3
4
|
const name = basename(item);
|
|
4
|
-
// `find -L` follows a symlink only for its direct target. fs.statSync mirrors that.
|
|
5
5
|
try {
|
|
6
|
-
if ((
|
|
7
|
-
const
|
|
8
|
-
|
|
6
|
+
if (fs.statSync(item).isDirectory()) {
|
|
7
|
+
const entries = fs.readdirSync(item).filter((entry) => !entry.startsWith("."));
|
|
8
|
+
const n = entries.length;
|
|
9
|
+
const summary = heading(`${item}/README.md`);
|
|
10
|
+
return summary ? `${summary} (${n} item(s))` : `${n} item(s)`;
|
|
9
11
|
}
|
|
10
12
|
}
|
|
11
13
|
catch {
|
|
@@ -23,13 +25,55 @@ function describe(item) {
|
|
|
23
25
|
}
|
|
24
26
|
return name;
|
|
25
27
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
function briefDiff(existing, next) {
|
|
29
|
+
const oldLines = existing.split("\n");
|
|
30
|
+
const newLines = next.split("\n");
|
|
31
|
+
const lines = ["--- existing", "+++ generated"];
|
|
32
|
+
const limit = Math.max(oldLines.length, newLines.length);
|
|
33
|
+
let shown = 0;
|
|
34
|
+
for (let i = 0; i < limit && shown < 20; i += 1) {
|
|
35
|
+
const a = oldLines[i];
|
|
36
|
+
const b = newLines[i];
|
|
37
|
+
if (a === b)
|
|
38
|
+
continue;
|
|
39
|
+
if (a !== undefined)
|
|
40
|
+
lines.push(`- ${a}`);
|
|
41
|
+
if (b !== undefined)
|
|
42
|
+
lines.push(`+ ${b}`);
|
|
43
|
+
shown += 1;
|
|
44
|
+
}
|
|
45
|
+
if (shown === 20)
|
|
46
|
+
lines.push("…");
|
|
47
|
+
return lines.join("\n");
|
|
48
|
+
}
|
|
49
|
+
function composeIndex(raw, existing) {
|
|
50
|
+
const stamped = stampGenerated(raw);
|
|
51
|
+
const fences = existing ? extractKeepFences(existing) : "";
|
|
52
|
+
if (!fences)
|
|
53
|
+
return stamped;
|
|
54
|
+
return `${stamped.replace(/\n+$/, "\n")}\n${fences}\n`;
|
|
55
|
+
}
|
|
56
|
+
function shouldOverwrite(context, existing, next) {
|
|
57
|
+
if (!existing)
|
|
58
|
+
return "write";
|
|
59
|
+
if (context.force)
|
|
60
|
+
return "write";
|
|
61
|
+
if (hasUnclosedKeepMarker(existing))
|
|
62
|
+
return "skip";
|
|
63
|
+
const stored = generatedStamp(existing);
|
|
64
|
+
if (stored && stored === generatedFingerprint(existing))
|
|
65
|
+
return "write";
|
|
66
|
+
if (stored)
|
|
67
|
+
return "refuse";
|
|
68
|
+
if (looksAutoGenerated(existing))
|
|
69
|
+
return "write";
|
|
70
|
+
if (generatedFingerprint(existing) === generatedFingerprint(next))
|
|
71
|
+
return "write";
|
|
72
|
+
return "refuse";
|
|
73
|
+
}
|
|
31
74
|
export function renderCompanyKnowledge(context) {
|
|
32
75
|
const written = [];
|
|
76
|
+
let refused = 0;
|
|
33
77
|
for (const companyDir of immediateEntries(context.root, "companies", "dir")) {
|
|
34
78
|
const company = basename(companyDir);
|
|
35
79
|
if (company.startsWith("_") || isHidden(company))
|
|
@@ -37,24 +81,24 @@ export function renderCompanyKnowledge(context) {
|
|
|
37
81
|
const relative = `companies/${company}/knowledge`;
|
|
38
82
|
const knowledge = `${companyDir}/knowledge`;
|
|
39
83
|
try {
|
|
40
|
-
if (!
|
|
84
|
+
if (!fs.statSync(knowledge).isDirectory())
|
|
41
85
|
continue;
|
|
42
86
|
}
|
|
43
87
|
catch {
|
|
44
88
|
continue;
|
|
45
89
|
}
|
|
46
|
-
const entries =
|
|
90
|
+
const entries = fs.readdirSync(knowledge).flatMap((name) => {
|
|
47
91
|
const item = `${knowledge}/${name}`;
|
|
48
92
|
try {
|
|
49
|
-
return
|
|
93
|
+
return fs.statSync(item).isDirectory() ? [item] : [];
|
|
50
94
|
}
|
|
51
95
|
catch {
|
|
52
96
|
return [];
|
|
53
97
|
}
|
|
54
|
-
}).sort().concat(
|
|
98
|
+
}).sort().concat(fs.readdirSync(knowledge).flatMap((name) => {
|
|
55
99
|
const item = `${knowledge}/${name}`;
|
|
56
100
|
try {
|
|
57
|
-
return
|
|
101
|
+
return fs.statSync(item).isFile() ? [item] : [];
|
|
58
102
|
}
|
|
59
103
|
catch {
|
|
60
104
|
return [];
|
|
@@ -68,18 +112,37 @@ export function renderCompanyKnowledge(context) {
|
|
|
68
112
|
const value = truncate(sanitize(describe(item)), 100) || "—";
|
|
69
113
|
let directory = false;
|
|
70
114
|
try {
|
|
71
|
-
directory =
|
|
115
|
+
directory = fs.statSync(item).isDirectory();
|
|
72
116
|
}
|
|
73
117
|
catch { /* omitted */ }
|
|
74
118
|
lines.push(`| \`${name}${directory ? "/" : ""}\` | ${value} |`);
|
|
75
119
|
}
|
|
76
120
|
lines.push("");
|
|
77
121
|
const output = `${relative}/INDEX.md`;
|
|
78
|
-
|
|
122
|
+
const raw = lines.join("\n");
|
|
123
|
+
const existing = readText(at(context.root, output));
|
|
124
|
+
const next = composeIndex(raw, existing);
|
|
125
|
+
const decision = shouldOverwrite(context, existing, next);
|
|
126
|
+
if (decision === "skip") {
|
|
127
|
+
log(context, `rebuild-company-knowledge-index: skipped ${output} (<!-- hq:keep -->)`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (decision === "refuse") {
|
|
131
|
+
refused += 1;
|
|
132
|
+
context.refused?.push(output);
|
|
133
|
+
log(context, `rebuild-company-knowledge-index: refused ${output} (hand-written or edited; differs from last generated output)`);
|
|
134
|
+
log(context, briefDiff(existing ?? "", next));
|
|
135
|
+
log(context, "Re-run with --accept to overwrite, or add <!-- hq:keep --> to opt out.");
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
write(context, output, next);
|
|
79
139
|
written.push(output);
|
|
80
140
|
log(context, `rebuild-company-knowledge-index: wrote ${output}`);
|
|
81
141
|
}
|
|
82
142
|
log(context, `rebuild-company-knowledge-index: regenerated ${written.length} knowledge INDEX.md file(s)`);
|
|
143
|
+
if (refused > 0) {
|
|
144
|
+
log(context, `rebuild-company-knowledge-index: refused to overwrite ${refused} hand-edited INDEX.md file(s)`);
|
|
145
|
+
}
|
|
83
146
|
return { written };
|
|
84
147
|
}
|
|
85
148
|
//# sourceMappingURL=company-knowledge.js.map
|
|
@@ -3,6 +3,10 @@ export type RenderContext = {
|
|
|
3
3
|
now?: Date;
|
|
4
4
|
log?: (message: string) => void;
|
|
5
5
|
output?: (message: string) => void;
|
|
6
|
+
/** Overwrite hand-edited INDEX.md files (`rebuild-index … --accept`). */
|
|
7
|
+
force?: boolean;
|
|
8
|
+
/** Relative paths that were not overwritten because they differ from last generated output. */
|
|
9
|
+
refused?: string[];
|
|
6
10
|
};
|
|
7
11
|
export type RenderResult = {
|
|
8
12
|
written: string[];
|
|
@@ -26,6 +30,14 @@ export declare function timestamp(now?: Date): string;
|
|
|
26
30
|
/** Write via a same-directory temporary file, then atomically replace the destination. */
|
|
27
31
|
export declare function atomicWrite(file: string, content: string): void;
|
|
28
32
|
export declare function write(context: RenderContext, relative: string, content: string): string;
|
|
33
|
+
/** Drop keep fences, the generated stamp, and the rolling date so two generated bodies can be compared. */
|
|
34
|
+
export declare function generatedFingerprint(content: string): string;
|
|
35
|
+
/** Insert a content stamp after the Auto-generated line. Idempotent. */
|
|
36
|
+
export declare function stampGenerated(content: string): string;
|
|
37
|
+
export declare function extractKeepFences(content: string): string;
|
|
38
|
+
export declare function hasUnclosedKeepMarker(content: string): boolean;
|
|
39
|
+
export declare function looksAutoGenerated(content: string): boolean;
|
|
40
|
+
export declare function generatedStamp(content: string): string | undefined;
|
|
29
41
|
export declare function log(context: RenderContext, message: string): void;
|
|
30
42
|
export declare function projectStatus(root: string, project: string, prdPath: string, fallback: string): string;
|
|
31
43
|
export declare function basename(file: string): string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
1
2
|
import * as fs from "fs";
|
|
2
3
|
import * as os from "os";
|
|
3
4
|
import * as path from "path";
|
|
@@ -96,6 +97,42 @@ export function write(context, relative, content) {
|
|
|
96
97
|
atomicWrite(at(context.root, relative), content);
|
|
97
98
|
return relative;
|
|
98
99
|
}
|
|
100
|
+
const GENERATED_DATE_LINE = /^> Auto-generated\. Updated: \d{4}-\d{2}-\d{2}$/m;
|
|
101
|
+
const GENERATED_STAMP_LINE = /^<!-- hq-generated-sha256: [a-f0-9]{64} -->\n?/m;
|
|
102
|
+
function keepFencePattern() {
|
|
103
|
+
return /<!-- hq:keep -->\r?\n?[\s\S]*?<!-- \/hq:keep -->/g;
|
|
104
|
+
}
|
|
105
|
+
/** Drop keep fences, the generated stamp, and the rolling date so two generated bodies can be compared. */
|
|
106
|
+
export function generatedFingerprint(content) {
|
|
107
|
+
const normalized = content
|
|
108
|
+
.replace(keepFencePattern(), "")
|
|
109
|
+
.replace(GENERATED_STAMP_LINE, "")
|
|
110
|
+
.replace(GENERATED_DATE_LINE, "> Auto-generated. Updated: <date>")
|
|
111
|
+
.replace(/\n+$/, "\n");
|
|
112
|
+
return createHash("sha256").update(normalized, "utf8").digest("hex");
|
|
113
|
+
}
|
|
114
|
+
/** Insert a content stamp after the Auto-generated line. Idempotent. */
|
|
115
|
+
export function stampGenerated(content) {
|
|
116
|
+
const unstamped = content.replace(GENERATED_STAMP_LINE, "");
|
|
117
|
+
const hash = generatedFingerprint(unstamped);
|
|
118
|
+
return unstamped.replace(GENERATED_DATE_LINE, (line) => `${line}\n<!-- hq-generated-sha256: ${hash} -->`);
|
|
119
|
+
}
|
|
120
|
+
export function extractKeepFences(content) {
|
|
121
|
+
return (content.match(keepFencePattern()) ?? []).join("\n\n");
|
|
122
|
+
}
|
|
123
|
+
export function hasUnclosedKeepMarker(content) {
|
|
124
|
+
const opens = content.match(/<!-- hq:keep -->/g)?.length ?? 0;
|
|
125
|
+
if (opens === 0)
|
|
126
|
+
return false;
|
|
127
|
+
const closes = content.match(/<!-- \/hq:keep -->/g)?.length ?? 0;
|
|
128
|
+
return opens > closes;
|
|
129
|
+
}
|
|
130
|
+
export function looksAutoGenerated(content) {
|
|
131
|
+
return GENERATED_DATE_LINE.test(content);
|
|
132
|
+
}
|
|
133
|
+
export function generatedStamp(content) {
|
|
134
|
+
return content.match(/^<!-- hq-generated-sha256: ([a-f0-9]{64}) -->/m)?.[1];
|
|
135
|
+
}
|
|
99
136
|
export function log(context, message) { context.log?.(message); }
|
|
100
137
|
export function projectStatus(root, project, prdPath, fallback) {
|
|
101
138
|
const state = readJson(at(root, `workspace/orchestrator/${project}/state.json`));
|
package/dist/utils/people.d.ts
CHANGED
|
@@ -86,17 +86,10 @@ export type ResolveResult = {
|
|
|
86
86
|
/**
|
|
87
87
|
* Resolve a person NAME to their email, built on top of {@link searchPeople}.
|
|
88
88
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* 3. substring search over name/email/slug
|
|
94
|
-
*
|
|
95
|
-
* The first tier that yields any match decides the result:
|
|
96
|
-
* - exactly one match with an email → `found`
|
|
97
|
-
* - exactly one match, no email → `no_email`
|
|
98
|
-
* - more than one match → `ambiguous` (caller disambiguates)
|
|
99
|
-
* - no match in any tier → `not_found`
|
|
89
|
+
* Resolution is deliberately stricter than a convenience ranking. Every
|
|
90
|
+
* case-insensitive name, email, or slug substring match is a candidate; an
|
|
91
|
+
* exact name never silently selects one person when the query also names
|
|
92
|
+
* other people. The caller must disambiguate any multi-person result.
|
|
100
93
|
*/
|
|
101
94
|
export declare function resolveNameToEmail(people: PersonRecord[], name: string): ResolveResult;
|
|
102
95
|
//# sourceMappingURL=people.d.ts.map
|
package/dist/utils/people.js
CHANGED
|
@@ -122,31 +122,16 @@ export function searchPeople(people, keyword) {
|
|
|
122
122
|
/**
|
|
123
123
|
* Resolve a person NAME to their email, built on top of {@link searchPeople}.
|
|
124
124
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* 3. substring search over name/email/slug
|
|
130
|
-
*
|
|
131
|
-
* The first tier that yields any match decides the result:
|
|
132
|
-
* - exactly one match with an email → `found`
|
|
133
|
-
* - exactly one match, no email → `no_email`
|
|
134
|
-
* - more than one match → `ambiguous` (caller disambiguates)
|
|
135
|
-
* - no match in any tier → `not_found`
|
|
125
|
+
* Resolution is deliberately stricter than a convenience ranking. Every
|
|
126
|
+
* case-insensitive name, email, or slug substring match is a candidate; an
|
|
127
|
+
* exact name never silently selects one person when the query also names
|
|
128
|
+
* other people. The caller must disambiguate any multi-person result.
|
|
136
129
|
*/
|
|
137
130
|
export function resolveNameToEmail(people, name) {
|
|
138
131
|
const query = name.trim();
|
|
139
132
|
if (!query)
|
|
140
133
|
return { status: "not_found" };
|
|
141
|
-
const
|
|
142
|
-
const exactName = people.filter((p) => p.name.toLowerCase() === lowered);
|
|
143
|
-
const exactSlug = people.filter((p) => p.slug.toLowerCase() === lowered);
|
|
144
|
-
const substring = searchPeople(people, query);
|
|
145
|
-
const matches = exactName.length > 0
|
|
146
|
-
? exactName
|
|
147
|
-
: exactSlug.length > 0
|
|
148
|
-
? exactSlug
|
|
149
|
-
: substring;
|
|
134
|
+
const matches = searchPeople(people, query);
|
|
150
135
|
if (matches.length === 0)
|
|
151
136
|
return { status: "not_found" };
|
|
152
137
|
if (matches.length > 1)
|