@indigoai-us/hq-cli 5.345.88 → 5.345.89

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.
@@ -0,0 +1,120 @@
1
+ # shellcheck shell=bash
2
+ # hq-core: public
3
+ # delegate-brainstorm.sh — shared readers for brainstorm-stage projects in the
4
+ # /delegate helpers.
5
+ #
6
+ # SOURCED, never executed: . "$ROOT/core/scripts/lib/delegate-brainstorm.sh"
7
+ # bash 3.2 safe; no associative arrays, no GNU-only flags.
8
+ #
9
+ # A brainstorm-stage project has companies/<co>/projects/<slug>/brainstorm.md
10
+ # and no prd.json yet (policy hq-brainstorm-leaves-handoffable-project-folder).
11
+ # Every delegation helper that reads the PRD falls back to these readers when
12
+ # the manifest says project.stage == "brainstorm".
13
+
14
+ # Project stage from the files on disk. Prints "prd", "brainstorm", or "none".
15
+ hq_delegate_project_stage() { # project_dir
16
+ if [ -f "$1/prd.json" ]; then
17
+ echo prd
18
+ elif [ -f "$1/brainstorm.md" ]; then
19
+ echo brainstorm
20
+ else
21
+ echo none
22
+ fi
23
+ }
24
+
25
+ # Value of a top-level `key: value` line inside the leading YAML frontmatter.
26
+ # Prints nothing when the file has no frontmatter or the key is absent.
27
+ hq_brainstorm_frontmatter_get() { # file key
28
+ awk -v key="$2" '
29
+ NR == 1 { if ($0 != "---") exit; next }
30
+ /^---[[:space:]]*$/ { exit }
31
+ {
32
+ split($0, kv, ":")
33
+ if (kv[1] == key) {
34
+ sub("^[^:]*:[[:space:]]*", "", $0)
35
+ gsub(/^"|"$/, "", $0)
36
+ print
37
+ exit
38
+ }
39
+ }' "$1"
40
+ }
41
+
42
+ # One-line summary: the first `> ` blockquote after the H1, else the first
43
+ # non-empty prose line after the H1.
44
+ hq_brainstorm_summary() { # file
45
+ awk '
46
+ /^# / && !seen_h1 { seen_h1 = 1; next }
47
+ !seen_h1 { next }
48
+ /^> / { sub(/^> /, ""); print; exit }
49
+ /^#/ { exit }
50
+ /^[[:space:]]*$/ { next }
51
+ { print; exit }' "$1"
52
+ }
53
+
54
+ # Project title: the first H1 without the leading `# `.
55
+ hq_brainstorm_title() { # file
56
+ awk '/^# / { sub(/^# /, ""); print; exit }' "$1"
57
+ }
58
+
59
+ # Body of a `## <heading>` section, up to the next `## ` heading. The heading
60
+ # is matched by prefix so `## Recommendation` also matches a decorated title.
61
+ hq_brainstorm_section() { # file heading
62
+ awk -v heading="$2" '
63
+ /^## / {
64
+ if (in_section) exit
65
+ if (index($0, "## " heading) == 1) { in_section = 1; next }
66
+ }
67
+ in_section { print }' "$1" | sed -e '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}'
68
+ }
69
+
70
+ # Newest journal note by filename, excluding the delegation log the transfer
71
+ # step maintains. Prints the HQ-root-relative path or nothing.
72
+ hq_brainstorm_latest_journal() { # hq_root rel_project_dir
73
+ local dir="$1/$2/journal" f newest="" name newest_name=""
74
+ [ -d "$dir" ] || return 0
75
+ # Bash's pathname expansion is sorted by locale like sort(1), and unlike
76
+ # find -maxdepth it works with both GNU and macOS/BSD find installations.
77
+ # Include dotfiles too: find includes them, while Bash's default glob does not.
78
+ for f in "$dir"/*.md "$dir"/.md "$dir"/.[!.]*.md "$dir"/..?*.md; do
79
+ [ -f "$f" ] || continue
80
+ name="${f##*/}"
81
+ [ "$name" = delegations.md ] && continue
82
+ case "$name" in *.md) ;; *) continue ;; esac
83
+ if [ -z "$newest" ]; then newest="$f"; newest_name="$name"; continue; fi
84
+ if [[ "$name" > "$newest_name" ]]; then newest="$f"; newest_name="$name"; fi
85
+ done
86
+ [ -n "$newest" ] || return 0
87
+ printf '%s\n' "${newest#"$1/"}"
88
+ }
89
+
90
+ # HQ-root-relative dossier paths for a brainstorm-stage project, one per line:
91
+ # brainstorm.md, every file under research/, the newest journal note.
92
+ hq_brainstorm_dossier() { # hq_root rel_project_dir
93
+ local root="$1" rel="$2"
94
+ printf '%s\n' "$rel/brainstorm.md"
95
+ if [ -d "$root/$rel/research" ]; then
96
+ (cd "$root" && find "$rel/research" -type f ! -name '.DS_Store' | sort)
97
+ fi
98
+ hq_brainstorm_latest_journal "$root" "$rel"
99
+ }
100
+
101
+ # Write or replace `key: value` inside the frontmatter, creating the
102
+ # frontmatter block when the file has none. Rewrites the file in place through
103
+ # a temp file so a failure leaves the original intact.
104
+ hq_brainstorm_frontmatter_set() { # file key value
105
+ local file="$1" key="$2" value="$3" tmp
106
+ tmp="$(mktemp "${file}.tmp.XXXXXX")"
107
+ if [ "$(head -1 "$file")" = "---" ]; then
108
+ awk -v key="$key" -v value="$value" '
109
+ NR == 1 { print; next }
110
+ !done && /^---[[:space:]]*$/ { print key ": " value; done = 1; print; next }
111
+ !done {
112
+ split($0, kv, ":")
113
+ if (kv[1] == key) { print key ": " value; done = 1; next }
114
+ }
115
+ { print }' "$file" > "$tmp"
116
+ else
117
+ { printf -- '---\n%s: %s\n---\n' "$key" "$value"; cat "$file"; } > "$tmp"
118
+ fi
119
+ mv -f "$tmp" "$file"
120
+ }
@@ -0,0 +1,9 @@
1
+ export type BrainstormStage = "prd" | "brainstorm" | "none";
2
+ export declare function projectStage(projectDir: string): BrainstormStage;
3
+ export declare function brainstormFrontmatterGet(file: string, key: string): string;
4
+ export declare function brainstormTitle(file: string): string;
5
+ export declare function brainstormSummary(file: string): string;
6
+ export declare function brainstormSection(file: string, heading: string): string;
7
+ export declare function brainstormDossier(root: string, relativeProject: string): string[];
8
+ export declare function brainstormFrontmatterSet(file: string, key: string, value: string): void;
9
+ //# sourceMappingURL=core-delegate-brainstorm.d.ts.map
@@ -0,0 +1,161 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ export function projectStage(projectDir) {
5
+ if (fs.existsSync(path.join(projectDir, "prd.json")))
6
+ return "prd";
7
+ if (fs.existsSync(path.join(projectDir, "brainstorm.md")))
8
+ return "brainstorm";
9
+ return "none";
10
+ }
11
+ export function brainstormFrontmatterGet(file, key) {
12
+ const lines = fs.readFileSync(file, "utf8").split("\n");
13
+ if (lines[0] !== "---")
14
+ return "";
15
+ for (const line of lines.slice(1)) {
16
+ if (/^---[\t ]*$/.test(line))
17
+ break;
18
+ const colon = line.indexOf(":");
19
+ if (colon < 0 || line.slice(0, colon) !== key)
20
+ continue;
21
+ return line.slice(colon + 1).replace(/^[\t ]*/, "").replace(/^"|"$/g, "");
22
+ }
23
+ return "";
24
+ }
25
+ export function brainstormTitle(file) {
26
+ return fs.readFileSync(file, "utf8").split("\n").find((line) => line.startsWith("# "))?.slice(2) ?? "";
27
+ }
28
+ export function brainstormSummary(file) {
29
+ let seenH1 = false;
30
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
31
+ if (!seenH1 && line.startsWith("# ")) {
32
+ seenH1 = true;
33
+ continue;
34
+ }
35
+ if (!seenH1)
36
+ continue;
37
+ if (line.startsWith("> "))
38
+ return line.slice(2);
39
+ if (line.startsWith("#"))
40
+ return "";
41
+ if (!line.trim())
42
+ continue;
43
+ return line;
44
+ }
45
+ return "";
46
+ }
47
+ export function brainstormSection(file, heading) {
48
+ const lines = fs.readFileSync(file, "utf8").split("\n");
49
+ const section = [];
50
+ let inSection = false;
51
+ for (const line of lines) {
52
+ if (line.startsWith("## ")) {
53
+ if (inSection)
54
+ break;
55
+ if (line.startsWith(`## ${heading}`)) {
56
+ inSection = true;
57
+ continue;
58
+ }
59
+ }
60
+ if (inSection)
61
+ section.push(line);
62
+ }
63
+ while (section[0] === "")
64
+ section.shift();
65
+ while (section.at(-1) === "")
66
+ section.pop();
67
+ return section.join("\n");
68
+ }
69
+ function filesBelow(directory) {
70
+ if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory())
71
+ return [];
72
+ const files = [];
73
+ const visit = (current) => {
74
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
75
+ const target = path.join(current, entry.name);
76
+ if (entry.isDirectory())
77
+ visit(target);
78
+ else if (entry.isFile() && entry.name !== ".DS_Store")
79
+ files.push(target);
80
+ }
81
+ };
82
+ visit(directory);
83
+ return files;
84
+ }
85
+ export function brainstormDossier(root, relativeProject) {
86
+ const projectDir = path.join(root, relativeProject);
87
+ const result = [`${relativeProject}/brainstorm.md`];
88
+ result.push(...filesBelow(path.join(projectDir, "research"))
89
+ .map((file) => path.relative(root, file).split(path.sep).join("/")).sort());
90
+ const journal = path.join(projectDir, "journal");
91
+ if (fs.existsSync(journal) && fs.statSync(journal).isDirectory()) {
92
+ const newest = fs.readdirSync(journal)
93
+ .filter((name) => name.endsWith(".md") && name !== "delegations.md" && isRegularFile(path.join(journal, name)))
94
+ .sort().at(-1);
95
+ if (newest)
96
+ result.push(`${relativeProject}/journal/${newest}`);
97
+ }
98
+ return result;
99
+ }
100
+ function isRegularFile(file) {
101
+ try {
102
+ return fs.statSync(file).isFile();
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ export function brainstormFrontmatterSet(file, key, value) {
109
+ const contents = fs.readFileSync(file, "utf8");
110
+ const lines = contents.split("\n");
111
+ let output;
112
+ if (lines[0] === "---") {
113
+ output = [];
114
+ let done = false;
115
+ let closed = false;
116
+ for (let index = 0; index < lines.length; index += 1) {
117
+ const line = lines[index] ?? "";
118
+ if (index === 0) {
119
+ output.push(line);
120
+ continue;
121
+ }
122
+ if (/^---[\t ]*$/.test(line)) {
123
+ if (!done) {
124
+ output.push(`${key}: ${value}`);
125
+ done = true;
126
+ }
127
+ output.push(line);
128
+ output.push(...lines.slice(index + 1));
129
+ closed = true;
130
+ break;
131
+ }
132
+ const colon = line.indexOf(":");
133
+ if (!done && colon >= 0 && line.slice(0, colon) === key) {
134
+ output.push(`${key}: ${value}`);
135
+ done = true;
136
+ }
137
+ else
138
+ output.push(line);
139
+ }
140
+ if (!closed)
141
+ output = output.length ? output : lines;
142
+ }
143
+ else {
144
+ output = ["---", `${key}: ${value}`, "---", ...lines];
145
+ }
146
+ const temporary = `${file}.tmp.${randomBytes(6).toString("hex")}`;
147
+ const rewritten = output.join("\n");
148
+ const contentsToWrite = lines[0] === "---" && !rewritten.endsWith("\n") ? `${rewritten}\n` : rewritten;
149
+ fs.writeFileSync(temporary, contentsToWrite, { mode: 0o600, flag: "wx" });
150
+ try {
151
+ fs.renameSync(temporary, file);
152
+ }
153
+ catch (error) {
154
+ try {
155
+ fs.rmSync(temporary, { force: true });
156
+ }
157
+ catch { /* retain primary failure */ }
158
+ throw error;
159
+ }
160
+ }
161
+ //# sourceMappingURL=core-delegate-brainstorm.js.map
@@ -1,5 +1,5 @@
1
1
  /** Native implementations of the second Wave 1 delegate command family. */
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomInt } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
@@ -13,7 +13,10 @@ import { ACL_SOURCE_DISPLAY_LABELS } from "../utils/files-browse-acl.js";
13
13
  import { runBundledScript } from "../utils/run-bundled-script.js";
14
14
  import { resolveWindowsBash } from "../utils/windows-bash.js";
15
15
  import { createShellCompatibleJqInvoker } from "../utils/jq-windows-compat.js";
16
+ import { brainstormDossier, brainstormFrontmatterGet, brainstormSection, brainstormSummary, projectStage } from "./core-delegate-brainstorm.js";
16
17
  const FLAG = "core-native-delegate";
18
+ const MKTEMP_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
19
+ const MKTEMP_SUFFIX_LENGTH = 10;
17
20
  const ASSETS = {
18
21
  bundle: "core/scripts/hq-delegate-bundle.sh",
19
22
  grant: "core/scripts/hq-delegate-grant.sh",
@@ -32,7 +35,7 @@ const USAGE = {
32
35
  bundle: "hq-delegate-bundle.sh — freeze a project into a portable delegation bundle.\n\nUsage:\n core/scripts/hq-delegate-bundle.sh build \\\n --company <slug> --project <name> --to <principal> \\\n [--to-kind person|agent] [--to-name <display name>] \\\n [--mode transfer|share] [--dry-run]\n\n--dry-run prints the manifest JSON to stdout and writes nothing.\n\nWrites workspace/delegations/<delegationId>/{manifest.json,BRIEF.md} and\n",
33
36
  grant: "hq-delegate-grant.sh — materialize a delegation's dossier in the vault and\nwrite the ACL grants its manifest declares, verifying each one landed.\n\nUsage:\n core/scripts/hq-delegate-grant.sh --manifest <path> [--yes]\n\nWithout --yes: prints the full grant plan (prefix, principal, permission —\nthe write grant is a privilege escalation and needs explicit confirmation)\nand exits 2 without mutating anything. The caller (the /delegate skill)\nconfirms with the user, then re-invokes with --yes.\n\nWith --yes:\n 1. hq sync push the project directory so the vault prefix exists\n 2. hq files share each manifest vaultPrefix to the recipient\n 3. read each grant back with hq files acl and match grantee + permission\n 4. advance manifest status building -> granted, stamping verifiedAt\n\nDirect ACL grants ONLY — this helper never mints a share-session URL\n(policy hq-delegate-never-inlines-secrets-or-share-urls).\n\nIdempotent: a prefix whose grant already exists is reported, not an error.\nA failed read-back exits non-zero and leaves manifest status unchanged.\n\n",
34
37
  send: "hq-delegate-send.sh — generate the self-sufficient pickup prompt for a\ndelegation and deliver it by DM. The prompt IS the handoff: pasted into a\nfresh agent session on a clean HQ install, it pulls every file on demand\n(hq files get per prefix — no /hq-sync run) and reaches the first\nnext-step with no follow-up question to the delegator.\n\nUsage:\n core/scripts/hq-delegate-send.sh --manifest <path> [--send]\n [--headline <text>] [--note <text>]\n\nWithout --send: (re)generates PICKUP-PROMPT.md next to the manifest and\nprints its path — inspectable, idempotent, sends nothing.\nWith --send: requires manifest status \"verified\" (the US-008 probe must\nhave passed), then delivers ONE `hq dm` with --prompt-file/--details-file\nand advances status to \"sent\", recording the DM eventId.\n\nThe caller (the /delegate skill) humanizes --headline / --note per\ncore/knowledge/public/hq-core/humanize-before-send.md (channel dm, light)\nBEFORE invoking; this helper never rewrites recipients, flags, or the\ngenerated command lines.\n\nFail-closed: the generated prompt is scanned for secret-shaped content and\nshare-session URLs; a match aborts before anything is written or sent\n(policy hq-delegate-never-inlines-secrets-or-share-urls).\n\n",
35
- verify: "hq-delegate-verify.sh — prove a delegation can actually be picked up\nbefore anyone is told it is theirs, or print the full plan without\ntouching anything.\n\nUsage (probe mode — after grants, before the DM):\n core/scripts/hq-delegate-verify.sh --manifest <path>\n\nProbes every manifest vaultPrefix with `hq files browse` and requires it\nreachable AND non-empty. All pass -> manifest advances granted -> verified\n(send is gated on this). Any failure -> non-zero, the failing prefix and\nlikely cause named, status left at its last successful value so a re-run\nresumes without re-granting.\n\nUsage (dry-run mode — full plan, zero mutation):\n core/scripts/hq-delegate-verify.sh --dry-run \\\n --company <slug> --project <name> --to <principal> [--mode transfer|share]\n\nPrints recipient, mode, every prefix with its permission, the secret\nnames an .env.schema would grant, repo + branch, and the DM headline that\nwould be sent. Exits 0. Creates nothing under workspace/delegations/ and\ninvokes no mutating command.\n\nEnv: HQ_ROOT — HQ root override (default: resolved from script location)\n",
38
+ verify: "hq-delegate-verify.sh — prove a delegation can actually be picked up\nbefore anyone is told it is theirs, or print the full plan without\ntouching anything.\n\nUsage (probe mode — after grants, before the DM):\n core/scripts/hq-delegate-verify.sh --manifest <path>\n\nProbes every manifest vaultPrefix with `hq files browse` and requires it\nreachable AND non-empty. All pass -> manifest advances granted -> verified\n(send is gated on this). Any failure -> non-zero, the failing prefix and\nlikely cause named, status left at its last successful value so a re-run\nresumes without re-granting.\n\nUsage (dry-run mode — full plan, zero mutation):\n core/scripts/hq-delegate-verify.sh --dry-run \\\n --company <slug> --project <name> --to <principal> [--mode transfer|share]\n\nPrints recipient, mode, project stage (prd, or brainstorm with the dossier\nfiles and the /plan next step), every prefix with its permission, the secret\nnames an .env.schema would grant, repo + branch, and the DM headline that\nwould be sent. Exits 0. Creates nothing under workspace/delegations/ and\ninvokes no mutating command.\n\n",
36
39
  publish: "usage: --manifest <path>",
37
40
  };
38
41
  function out(value) { process.stdout.write(value); }
@@ -56,6 +59,9 @@ function currentTime() {
56
59
  const date = value === undefined ? new Date() : new Date(value);
57
60
  return Number.isNaN(date.getTime()) ? "Invalid Date" : date.toISOString().replace(/\.\d{3}Z$/, "Z");
58
61
  }
62
+ function mktempCompatibleSuffix() {
63
+ return Array.from({ length: MKTEMP_SUFFIX_LENGTH }, () => MKTEMP_ALPHABET[randomInt(MKTEMP_ALPHABET.length)]).join("");
64
+ }
59
65
  function jsonFile(file) {
60
66
  try {
61
67
  const value = readJsonFile(file);
@@ -254,17 +260,23 @@ function runBundle(args, context, onMalformedWhoami) {
254
260
  const prdPath = path.join(projectDir, "prd.json");
255
261
  if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory())
256
262
  return fail("bundle", `project directory not found: companies/${company}/projects/${project}`);
257
- if (!fs.existsSync(prdPath) || !fs.statSync(prdPath).isFile())
258
- return fail("bundle", `project has no prd.json: companies/${company}/projects/${project}/prd.json`);
263
+ const stage = projectStage(projectDir);
264
+ if (stage === "none")
265
+ return fail("bundle", `project has neither prd.json nor brainstorm.md: companies/${company}/projects/${project} (run /brainstorm or /plan first)`);
259
266
  let prd;
260
- try {
261
- prd = readJsonFile(prdPath);
262
- }
263
- catch {
264
- return fail("bundle", `prd.json is not valid JSON: ${prdPath}`);
267
+ if (stage === "prd") {
268
+ try {
269
+ prd = readJsonFile(prdPath);
270
+ }
271
+ catch {
272
+ return fail("bundle", `prd.json is not valid JSON: ${prdPath}`);
273
+ }
274
+ if (prd === null || prd === false)
275
+ return fail("bundle", `prd.json is not valid JSON: ${prdPath}`);
265
276
  }
266
- if (prd === null || prd === false)
267
- return fail("bundle", `prd.json is not valid JSON: ${prdPath}`);
277
+ const projectRelative = `companies/${company}/projects/${project}`;
278
+ const brainstormPath = path.join(projectDir, "brainstorm.md");
279
+ const brainstormRelative = `${projectRelative}/brainstorm.md`;
268
280
  const who = subprocess("hq", ["whoami", "--json"], context.cwd, { stdout: "capture" });
269
281
  if (who.code !== 0)
270
282
  return fail("bundle", "could not read sender identity; update hq-cli if --json is unsupported");
@@ -283,7 +295,8 @@ function runBundle(args, context, onMalformedWhoami) {
283
295
  return fail("bundle", "sender identity is missing or expired; run hq login");
284
296
  const fromEmail = text(identityValue.email) || null;
285
297
  const fromUid = text(identityValue.personUid) || text(identityValue.agentUid) || null;
286
- const knowledge = list(getPath(prd, "metadata.knowledge", [])).filter((entry) => typeof entry === "string").map(String);
298
+ const knowledge = (stage === "prd" ? list(getPath(prd, "metadata.knowledge", [])) : [])
299
+ .filter((entry) => typeof entry === "string").map(String);
287
300
  const readPrefixes = [...new Set(knowledge.filter((entry) => entry.startsWith(`companies/${company}/`)).map((entry) => {
288
301
  const relative = entry.slice(`companies/${company}/`.length);
289
302
  if (relative.endsWith("/"))
@@ -297,7 +310,7 @@ function runBundle(args, context, onMalformedWhoami) {
297
310
  ].sort((a, b) => a.prefix.localeCompare(b.prefix));
298
311
  if (vaultPrefixes.some(({ prefix }) => !prefix.endsWith("/") || prefix.startsWith("companies/")))
299
312
  return fail("bundle", "internal error: derived a non-folder or company-anchored prefix");
300
- const repoPath = text(getPath(prd, "metadata.repoPath"));
313
+ const repoPath = stage === "prd" ? text(getPath(prd, "metadata.repoPath")) : "";
301
314
  const repo = repoPath ? {
302
315
  path: repoPath,
303
316
  remote: null,
@@ -312,7 +325,9 @@ function runBundle(args, context, onMalformedWhoami) {
312
325
  const board = jsonFile(boardFile);
313
326
  if (board) {
314
327
  const projects = list(board.projects);
315
- const boardEntry = projects.find((entry) => isRecord(entry) && entry.prd_path === `companies/${company}/projects/${project}/prd.json`);
328
+ const lookupPath = stage === "prd" ? `${projectRelative}/prd.json` : brainstormRelative;
329
+ const lookupKey = stage === "prd" ? "prd_path" : "brainstorm_path";
330
+ const boardEntry = projects.find((entry) => isRecord(entry) && entry[lookupKey] === lookupPath);
316
331
  if (isRecord(boardEntry) && jqTrue(boardEntry.id))
317
332
  boardId = boardEntry.id;
318
333
  }
@@ -323,6 +338,9 @@ function runBundle(args, context, onMalformedWhoami) {
323
338
  const idTime = createdAt.replace(/[-:TZ]/g, "").slice(0, 8) + "-" + createdAt.slice(11, 19).replaceAll(":", "");
324
339
  const delegationId = `dlg-${idTime}-${project}`;
325
340
  const policies = knowledge.filter((entry) => entry.startsWith(`companies/${company}/policies/`));
341
+ const projectManifest = stage === "prd"
342
+ ? { name: project, stage, prdPath: `${projectRelative}/prd.json`, sourcePath: `${projectRelative}/prd.json`, boardId }
343
+ : { name: project, stage, prdPath: null, sourcePath: brainstormRelative, boardId, dossier: brainstormDossier(root, projectRelative) };
326
344
  const manifest = {
327
345
  schemaVersion: 1,
328
346
  delegationId,
@@ -331,7 +349,7 @@ function runBundle(args, context, onMalformedWhoami) {
331
349
  from: { email: fromEmail, personUid: fromUid },
332
350
  to: { kind: toKind, principal: recipient, displayName: toName || null },
333
351
  company,
334
- project: { name: project, prdPath: `companies/${company}/projects/${project}/prd.json`, boardId },
352
+ project: projectManifest,
335
353
  vaultPrefixes: vaultPrefixes.filter((entry, index, all) => all.findIndex((candidate) => candidate.prefix === entry.prefix) === index),
336
354
  repo,
337
355
  secrets: [],
@@ -340,6 +358,16 @@ function runBundle(args, context, onMalformedWhoami) {
340
358
  checksums,
341
359
  status: "building",
342
360
  };
361
+ if (stage === "brainstorm") {
362
+ const summary = brainstormSummary(brainstormPath) || "No summary recorded in brainstorm.md.";
363
+ const recommendation = brainstormSection(brainstormPath, "Recommendation");
364
+ const unknowns = brainstormSection(brainstormPath, "What We Don't Know");
365
+ const dossierList = projectManifest.dossier
366
+ .map((entry) => `- \`${entry}\``)
367
+ .join("\n");
368
+ const brief = `# Delegation brief — ${project}\n\nThis brief describes the **${project}** project in the **${company}** company. Completed handoff steps are recorded in the manifest. Pull the files and acknowledge receipt; report any access error to the sender.\n\n## What this project is\n\n${summary}\n\n## Where things stand\n\nThis project is at the brainstorm stage. There is no PRD yet and no stories have been written; the source of truth is \`${projectRelative}/brainstorm.md\`, with research notes and the latest session journal alongside it.\n\n${recommendation ? `## Recommendation so far\n\n${recommendation}\n\n` : ""}${unknowns ? `## Open questions\n\n${unknowns}\n\n` : ""}## Recommended next step\n\nRun \`/plan ${project}\` to turn the brainstorm into a PRD with stories. Read brainstorm.md and the research notes first; the recommendation and open questions above come from them.\n\n## Where everything lives\n\nThe dossier for this handoff:\n\n${dossierList}\n\nThe pickup prompt that accompanied this brief pulls every file you need on demand — you do not need to run a full sync.\n`;
369
+ return writeBundle(context, { manifest, brief, dryRun, project });
370
+ }
343
371
  const description = text(getPath(prd, "description"), "No description recorded.");
344
372
  const goal = text(getPath(prd, "metadata.goal"));
345
373
  const stories = list(getPath(prd, "userStories", []));
@@ -365,27 +393,54 @@ function runBundle(args, context, onMalformedWhoami) {
365
393
  if (traps)
366
394
  brief += `\n## Known traps and conventions\n\n${traps}\n`;
367
395
  brief += `\n## Where everything lives\n\nThe full PRD (every story, acceptance criteria, and decision history) is at \`companies/${company}/projects/${project}/prd.json\`. The pickup prompt that accompanied this brief pulls every file you need on demand — you do not need to run a full sync.\n`;
368
- const stage = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-"));
396
+ return writeBundle(context, { manifest, brief, dryRun, project });
397
+ }
398
+ function writeBundle(context, input) {
399
+ const root = context.root ?? context.cwd;
400
+ const { manifest, brief, dryRun, project } = input;
401
+ const delegationId = jqRawPath(manifest, "delegationId");
402
+ const destinationParent = path.join(root, "workspace", "delegations");
403
+ // The shell uses `mktemp -d`, which honors TMPDIR on every supported shell.
404
+ const stageBase = process.env.TMPDIR || os.tmpdir();
405
+ const stage = path.join(stageBase, `tmp.${mktempCompatibleSuffix()}`);
406
+ fs.mkdirSync(stage, { mode: 0o700 });
407
+ const bundleFileSystem = context.bundleFileSystem ?? fs;
369
408
  const manifestFile = path.join(stage, "manifest.json");
370
409
  const briefFile = path.join(stage, "BRIEF.md");
371
- fs.writeFileSync(manifestFile, jsonText(manifest));
372
- fs.writeFileSync(briefFile, brief);
373
- if (!secretScan("bundle", [manifestFile, briefFile])) {
374
- fs.rmSync(stage, { recursive: true, force: true });
375
- return fail("bundle", "generated bundle matched a secret-detection pattern — nothing written (see stderr above)");
376
- }
377
- if (dryRun) {
378
- out(fs.readFileSync(manifestFile, "utf8"));
379
- fs.rmSync(stage, { recursive: true, force: true });
380
- err("hq-delegate-bundle: dry run — nothing written\n");
410
+ try {
411
+ fs.writeFileSync(manifestFile, jsonText(manifest));
412
+ fs.writeFileSync(briefFile, brief);
413
+ if (!secretScan("bundle", [manifestFile, briefFile])) {
414
+ return fail("bundle", "generated bundle matched a secret-detection pattern — nothing written (see stderr above)");
415
+ }
416
+ if (dryRun) {
417
+ out(fs.readFileSync(manifestFile, "utf8"));
418
+ err("hq-delegate-bundle: dry run — nothing written\n");
419
+ return 0;
420
+ }
421
+ const dest = path.join(destinationParent, delegationId);
422
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
423
+ try {
424
+ bundleFileSystem.renameSync(stage, dest);
425
+ }
426
+ catch (error) {
427
+ const code = typeof error === "object" && error !== null && "code" in error
428
+ ? error.code
429
+ : undefined;
430
+ if (code !== "EXDEV" && code !== "EPERM")
431
+ throw error;
432
+ // `mv` crosses volumes by recursively copying a directory before
433
+ // removing the source. copyFileSync cannot handle this directory.
434
+ bundleFileSystem.cpSync(stage, dest, { recursive: true });
435
+ bundleFileSystem.rmSync(stage, { recursive: true, force: true });
436
+ }
437
+ out(`${delegationId}\n`);
438
+ err(`hq-delegate-bundle: wrote workspace/delegations/${delegationId}/{manifest.json,BRIEF.md}\n`);
381
439
  return 0;
382
440
  }
383
- const dest = path.join(root, "workspace", "delegations", delegationId);
384
- fs.mkdirSync(path.dirname(dest), { recursive: true });
385
- moveFile(stage, dest);
386
- out(`${delegationId}\n`);
387
- err(`hq-delegate-bundle: wrote workspace/delegations/${delegationId}/{manifest.json,BRIEF.md}\n`);
388
- return 0;
441
+ finally {
442
+ bundleFileSystem.rmSync(stage, { recursive: true, force: true });
443
+ }
389
444
  }
390
445
  function runHq(args, context, stdout = "capture") {
391
446
  return subprocess("hq", args, context.cwd, { stdout });
@@ -737,22 +792,41 @@ function runPublish(args, context) {
737
792
  const bundleDir = path.dirname(path.resolve(manifestPath));
738
793
  const brief = path.join(bundleDir, "BRIEF.md");
739
794
  const prd = path.join(projectDir, "prd.json");
795
+ const brainstorm = path.join(projectDir, "brainstorm.md");
796
+ const stageKind = jqRawPath(manifest, "project.stage", "prd");
740
797
  if (!fs.existsSync(brief) || !fs.statSync(brief).isFile())
741
798
  return fail("publish", "BRIEF.md missing");
742
- if (!fs.existsSync(prd) || !fs.statSync(prd).isFile())
799
+ if (stageKind === "prd" && (!fs.existsSync(prd) || !fs.statSync(prd).isFile()))
743
800
  return fail("publish", "PRD missing");
801
+ if (stageKind === "brainstorm" && (!fs.existsSync(brainstorm) || !fs.statSync(brainstorm).isFile()))
802
+ return fail("publish", "brainstorm.md missing");
803
+ if (stageKind !== "prd" && stageKind !== "brainstorm")
804
+ return fail("publish", `unknown project stage '${stageKind}'`);
744
805
  if (jqRawPath(manifest, "mode") === "transfer") {
745
806
  const expectedOwner = jqRawPath(manifest, "to.principal");
746
- const prdValue = jsonFile(prd);
747
- if (!prdValue || jqRawPath(prdValue, "metadata.owner") !== expectedOwner)
748
- return fail("publish", "local PRD owner does not match recipient");
807
+ if (stageKind === "prd") {
808
+ const prdValue = jsonFile(prd);
809
+ if (!prdValue || jqRawPath(prdValue, "metadata.owner") !== expectedOwner)
810
+ return fail("publish", "local PRD owner does not match recipient");
811
+ }
812
+ else if (brainstormFrontmatterGet(brainstorm, "owner") !== expectedOwner)
813
+ return fail("publish", "local brainstorm.md owner does not match recipient");
749
814
  }
750
815
  stage = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-"));
751
816
  const stagedBrief = path.join(stage, "BRIEF.md");
752
817
  fs.copyFileSync(brief, stagedBrief);
753
818
  const hashFile = path.join(stage, "hashes");
754
819
  const hashed = {};
755
- for (const file of [prd, path.join(projectDir, "README.md"), path.join(projectDir, "journal", "delegations.md")]) {
820
+ const snapshotFiles = stageKind === "prd"
821
+ ? [prd, path.join(projectDir, "README.md"), path.join(projectDir, "journal", "delegations.md")]
822
+ : [...list(getPath(manifest, "project.dossier", [])).map((entry) => path.join(root, text(entry))), path.join(projectDir, "journal", "delegations.md")];
823
+ if (stageKind === "brainstorm") {
824
+ for (const file of snapshotFiles.slice(0, -1)) {
825
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile())
826
+ return fail("publish", `dossier file missing: ${path.relative(root, file).split(path.sep).join("/")}`);
827
+ }
828
+ }
829
+ for (const file of snapshotFiles) {
756
830
  if (fs.existsSync(file) && fs.statSync(file).isFile()) {
757
831
  const key = path.relative(root, file).split(path.sep).join("/");
758
832
  hashed[key] = checksum(file);
@@ -891,10 +965,20 @@ function runSend(args, context) {
891
965
  const prdRelative = jqRawPath(manifest, "project.prdPath");
892
966
  const prdAbs = path.join(root, prdRelative);
893
967
  const prd = fs.existsSync(prdAbs) ? jsonFile(prdAbs) : undefined;
968
+ const stageKind = jqRawPath(manifest, "project.stage", "prd");
969
+ const brainstormAbs = path.join(root, "companies", company, "projects", project, "brainstorm.md");
894
970
  let goal = "";
895
971
  let stateLine = "";
896
972
  let nextSteps = "";
897
- if (prd) {
973
+ let dossierLine = `Start with the project dossier: \`companies/${company}/projects/${project}/\` now contains the PRD (\`prd.json\`), a full brief (\`README.md\` / the DM's details pane), and the delegation journal.`;
974
+ if (stageKind === "brainstorm") {
975
+ if (fs.existsSync(brainstormAbs))
976
+ goal = brainstormSummary(brainstormAbs);
977
+ stateLine = `This project is at the brainstorm stage: no PRD yet, no stories. The source of truth is \`companies/${company}/projects/${project}/brainstorm.md\`.`;
978
+ nextSteps = `1. **Read the brainstorm** — \`brainstorm.md\`, the notes under \`research/\`, and the latest journal entry under \`journal/\`.\n2. **Turn it into a PRD** — run \`/plan ${project}\` to write \`prd.json\` with stories.\n3. **Start the first story** — once the PRD exists, \`/run-project ${project}\` or \`/execute-task\` picks it up.`;
979
+ dossierLine = `Start with the project dossier: \`companies/${company}/projects/${project}/\` contains \`brainstorm.md\`, the \`research/\` notes, the latest journal entry, a full brief (the DM's details pane), and the delegation journal. There is no \`prd.json\` yet.`;
980
+ }
981
+ else if (prd) {
898
982
  goal = text(jqFallback(getPath(prd, "metadata.goal"), getPath(prd, "description", "")));
899
983
  const stories = list(getPath(prd, "userStories", []));
900
984
  const complete = stories.filter((entry) => isRecord(entry) && entry.passes === true).length;
@@ -935,7 +1019,7 @@ function runSend(args, context) {
935
1019
  body += `## The goal\n\n${goal}\n\n`;
936
1020
  if (stateLine)
937
1021
  body += `## Where things stand\n\n${stateLine}\n\n`;
938
- body += `## Pull the files (on demand — no sync)\n\nRun these from your HQ root; each materializes exactly that path locally:\n\n\`\`\`bash\n${getLines}\n\`\`\`\n\nStart with the project dossier: \`companies/${company}/projects/${project}/\` now contains the PRD (\`prd.json\`), a full brief (\`README.md\` / the DM's details pane), and the delegation journal.\n\n`;
1022
+ body += `## Pull the files (on demand — no sync)\n\nRun these from your HQ root; each materializes exactly that path locally:\n\n\`\`\`bash\n${getLines}\n\`\`\`\n\n${dossierLine}\n\n`;
939
1023
  if (repoSection)
940
1024
  body += `${repoSection}\n\n`;
941
1025
  if (secretsSection)
@@ -1090,7 +1174,17 @@ function runVerify(args, context) {
1090
1174
  secretNames = names.length ? `${names.join(" ")} ` : "(none declared)";
1091
1175
  }
1092
1176
  out("Delegation dry run — nothing will be pushed, granted, transferred, or sent.\n\n");
1093
- out(` Recipient: ${recipient}\n Mode: ${mode}\n Company: ${company}\n Project: ${project}\n\n`);
1177
+ const stageKind = jqRawPath(manifest, "project.stage", "prd");
1178
+ out(` Recipient: ${recipient}\n Mode: ${mode}\n Company: ${company}\n Project: ${project}\n`);
1179
+ if (stageKind === "brainstorm") {
1180
+ out(` Stage: brainstorm — no PRD yet; source of truth is brainstorm.md; recommended next step is /plan ${project}\n\n`);
1181
+ out(" Dossier files that would be published:\n");
1182
+ for (const file of list(getPath(manifest, "project.dossier", [])))
1183
+ out(` ${text(file)}\n`);
1184
+ }
1185
+ else
1186
+ out(" Stage: prd\n");
1187
+ out("\n");
1094
1188
  out(" Vault grants that would be written:\n");
1095
1189
  for (const entry of list(getPath(manifest, "vaultPrefixes", [])))
1096
1190
  out(` ${jqRawPath(entry, "permission")}\ton ${jqRawPath(entry, "prefix")}\t(${jqRawPath(entry, "reason")})\n`);
@@ -7,8 +7,10 @@ export type DelegateContext = {
7
7
  cwd: string;
8
8
  root?: string;
9
9
  atomicWriteFileSystem?: DelegateAtomicWriteFileSystem;
10
+ bundleFileSystem?: DelegateBundleFileSystem;
10
11
  };
11
12
  export type DelegateAtomicWriteFileSystem = Pick<typeof fs, "openSync" | "writeFileSync" | "closeSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync">;
13
+ export type DelegateBundleFileSystem = Pick<typeof fs, "renameSync" | "cpSync" | "rmSync">;
12
14
  type CommandResult = {
13
15
  code: number;
14
16
  stdout?: string;