@indigoai-us/hq-cli 5.345.88 → 5.345.90

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.
@@ -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;
@@ -11,6 +11,7 @@ import { buildCmdExeSpawnPlan, isWindowsBatchFile } from "../utils/windows-spawn
11
11
  import { describeCoreCommand } from "./core-command-catalog.js";
12
12
  import { resolveLiveRoot } from "../utils/hq-roots.js";
13
13
  import { runBundledScript } from "../utils/run-bundled-script.js";
14
+ import { brainstormFrontmatterSet, brainstormSummary, brainstormTitle } from "./core-delegate-brainstorm.js";
14
15
  export const CORE_NATIVE_DELEGATE_FLAG = "core-native-delegate";
15
16
  const SCRIPT_ASSETS = {
16
17
  resolve: "core/scripts/hq-delegate-resolve.sh",
@@ -52,7 +53,7 @@ const USAGE_TEXT = {
52
53
  pickup: "# hq-core: public\n# hq-delegate-pickup.sh — close the delegation loop with a receipt or a FAILED\n# state (2026-09-07).\n#\n# Why: the delegate pipeline ended at \"sent\", which is a send receipt, not a\n# delivery receipt. Seven delegations sat at sent/building with no way to tell\n# whether the recipient could act. This script adds the two missing states:\n#\n# sent --(evidence)--> picked-up recipient demonstrably has the work\n# sent --(silence past window)--> failed nobody picked it up; sender is told why\n#\n# Evidence, checked in order (any one suffices):\n# 1. --ack \"<text>\" an explicit acknowledgement (DM reply, chat) —\n# recorded verbatim with a timestamp\n# 2. repo branch has a commit by the recipient after sentAt\n# (manifest.repo.path + repo.branch, author email == to.principal)\n# 3. --evidence \"<free text>\" any other proof the operator observed\n#\n# Usage:\n# hq-delegate-pickup.sh --manifest <path> --ack \"<text>\" # record receipt\n# hq-delegate-pickup.sh --manifest <path> --evidence \"<text>\" # record receipt\n# hq-delegate-pickup.sh --manifest <path> --check [--window-hours N] # probe repo; fail if silent past window (default 72)\n# hq-delegate-pickup.sh --manifest <path> --status # print state line\n#\n# Exit 0: picked-up (or already). Exit 5: failed (window elapsed, no evidence).\n# Exit 6: still waiting (inside window, no evidence). Exit 2: usage/precondition.\nset -uo pipefail\n",
53
54
  repo: "hq-delegate-repo.sh — code/branch handover for a delegation: make sure the\nproject's branch exists on the remote, record uncommitted work as NOT\ntransferred, and verify the recipient's repository access read-only.\n\nUsage:\n core/scripts/hq-delegate-repo.sh --manifest <path> [--yes] [--github-user <login>]\n\nBehavior (driven by the manifest's repo{} block; no-op when repo is null):\n - records remote URL and the branch's headSha into the manifest\n - a branch that exists only locally requires confirmation: without --yes\n the helper prints the plan and exits 2; with --yes it pushes via an\n explicitly anchored `git -C <abs path> push -u origin <branch>`\n - dirty/untracked files are captured into repo.dirtyFiles[] and appended\n to BRIEF.md as work NOT transferred — never silently dropped\n - recipient access is checked read-only via gh when --github-user is\n given; otherwise (or on a gh failure) repo.accessVerified stays false\n and the brief says so plainly — the helper never claims unverified access\n - never touches vaultPrefixes[] and never pushes repo content to the vault\n\nEnv: HQ_ROOT — HQ root override (default: resolved from script location)\n",
54
55
  secrets: "hq-delegate-secrets.sh — grant a delegation recipient access to the secrets\na project needs, BY NAME ONLY. No command in this helper ever reads,\nprints, logs, or interpolates a secret value.\n\nUsage:\n core/scripts/hq-delegate-secrets.sh --manifest <path> [--yes] [--no-secrets]\n [--env-schema <path>]\n\nBehavior:\n - derives required secret NAMES from the project's / repo's .env.schema\n (--env-schema overrides discovery); no schema -> records secrets: []\n and says so plainly rather than guessing at credentials\n - without --yes: prints a full-prose confirmation listing every name and\n the principal, then exits 2 granting nothing — this step is a privilege\n handover and gets its own confirmation, separate from the file grants\n - with --yes: hq secrets share <NAME> --with <principal> --permission read\n per name, then records the names (and only the names) in the manifest\n - --no-secrets: skips everything, records {secrets: [], secretsSkipped: true}\n\nConsumption on the recipient's side is via `hq run` / `hq secrets exec` —\ndocumented in the brief; values never appear in any delegation artifact\n(policy hq-delegate-never-inlines-secrets-or-share-urls).\n",
55
- transfer: "hq-delegate-transfer.sh — make a delegation real everywhere HQ tracks\nownership: board entry, PRD metadata, work mesh, and the project journal.\n\nUsage:\n core/scripts/hq-delegate-transfer.sh --manifest <path>\n\nBehavior (mode \"transfer\"; mode \"share\" skips every mutation):\n 1. companies/<co>/board.json — the project's entry gets owner=<recipient>\n and a bumped updated_at; created (once) if absent\n 2. prd.json metadata gains owner, delegatedFrom, delegatedAt\n 3. work-mesh: `done` event closes the delegator's in-progress thread and\n broadcasts the reassignment; silently skipped when the helper is\n unavailable (local/offline installs) — the transfer still succeeds\n 4. a dated \"Delegated\" stanza is appended to the project journal naming\n the recipient, delegation id, and transferred scope\n\nIdempotent: re-running updates owner/timestamps in place — never a\nduplicate board entry, never a duplicated journal stanza.\n\nThe delegator's own vault access is untouched (granting the recipient\nwrite does not revoke anything).\n\nEnv: HQ_ROOT — HQ root override (default: resolved from script location)\n",
56
+ transfer: "hq-delegate-transfer.sh — make a delegation real everywhere HQ tracks\nownership: board entry, PRD metadata, work mesh, and the project journal.\n\nUsage:\n core/scripts/hq-delegate-transfer.sh --manifest <path>\n\nBehavior (mode \"transfer\"; mode \"share\" skips every mutation):\n 1. companies/<co>/board.json — the project's entry gets owner=<recipient>\n and a bumped updated_at; created (once) if absent. A brainstorm-stage\n entry (status exploring, brainstorm_path set) is matched by\n brainstorm_path and keeps its status.\n 2. prd.json metadata gains owner, delegatedFrom, delegatedAt; for a\n brainstorm-stage project the same three keys land in the\n brainstorm.md frontmatter (owner, delegated_from, delegated_at)\n 3. work-mesh: `done` event closes the delegator's in-progress thread and\n broadcasts the reassignment; silently skipped when the helper is\n unavailable (local/offline installs) — the transfer still succeeds\n 4. a dated \"Delegated\" stanza is appended to the project journal naming\n the recipient, delegation id, and transferred scope\n\nIdempotent: re-running updates owner/timestamps in place — never a\nduplicate board entry, never a duplicated journal stanza.\n\n",
56
57
  };
57
58
  function usage(name) {
58
59
  return USAGE_TEXT[name];
@@ -189,7 +190,6 @@ export function moveFile(source, target, fileSystem = fs) {
189
190
  }
190
191
  export function writeJsonAtomically(target, value, siblingTemp = false, fileSystem = fs) {
191
192
  const serialized = serializeJqJson(value, "pretty");
192
- // Native delegate JSON writers use LF on every platform.
193
193
  if (siblingTemp) {
194
194
  const temporary = `${target}.tmp.${randomBytes(6).toString("hex")}`;
195
195
  let fd;
@@ -918,14 +918,25 @@ function runTransfer(args, context) {
918
918
  }
919
919
  const now = jqRawPath(document, "ownershipTransferredAt") || new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
920
920
  const today = (process.env.HQ_DELEGATE_NOW || new Date().toISOString()).slice(0, 10);
921
+ const stage = jqRawPath(document, "project.stage", "prd");
921
922
  const prdPath = `companies/${company}/projects/${project}/prd.json`;
922
923
  const prdAbs = path.join(root, prdPath);
924
+ const brainstormPath = `companies/${company}/projects/${project}/brainstorm.md`;
925
+ const brainstormAbs = path.join(root, brainstormPath);
923
926
  const projectDir = path.join(root, "companies", company, "projects", project);
924
927
  const board = path.join(root, "companies", company, "board.json");
925
- if (!fs.existsSync(prdAbs) || !fs.statSync(prdAbs).isFile()) {
928
+ if (stage !== "prd" && stage !== "brainstorm") {
929
+ writeErr(`${prefix} manifest project.stage is '${stage}' — expected prd or brainstorm\n`);
930
+ return 1;
931
+ }
932
+ if (stage === "prd" && (!fs.existsSync(prdAbs) || !fs.statSync(prdAbs).isFile())) {
926
933
  writeErr(`${prefix} prd not found: ${prdPath}\n`);
927
934
  return 1;
928
935
  }
936
+ if (stage === "brainstorm" && (!fs.existsSync(brainstormAbs) || !fs.statSync(brainstormAbs).isFile())) {
937
+ writeErr(`${prefix} brainstorm.md not found: ${brainstormPath}\n`);
938
+ return 1;
939
+ }
929
940
  if (fs.existsSync(board)) {
930
941
  if (!jqValid(board)) {
931
942
  writeErr(`${prefix} board.json is not valid JSON: ${board}\n`);
@@ -936,9 +947,9 @@ function runTransfer(args, context) {
936
947
  fs.mkdirSync(path.dirname(board), { recursive: true });
937
948
  fs.writeFileSync(board, '{"projects": []}\n');
938
949
  }
939
- const prd = readJsonFile(prdAbs);
940
- const title = jqRawPath(prd, "name");
941
- const descriptionValue = jqFallback(jsonAt(prd, "description"), "");
950
+ const prd = stage === "prd" ? readJsonFile(prdAbs) : undefined;
951
+ const title = stage === "prd" ? jqRawPath(prd, "name") : brainstormTitle(brainstormAbs);
952
+ const descriptionValue = stage === "prd" ? jqFallback(jsonAt(prd, "description"), "") : brainstormSummary(brainstormAbs);
942
953
  const description = Buffer.from(serializeJqJson(descriptionValue, "raw"), "utf8")
943
954
  .subarray(0, 300).toString("utf8").replace(/\n+$/, "");
944
955
  const boardDocument = readJsonFile(board);
@@ -948,35 +959,46 @@ function runTransfer(args, context) {
948
959
  const projects = Array.isArray(projectsValue)
949
960
  ? projectsValue
950
961
  : isRecord(projectsValue) ? Object.values(projectsValue) : [];
951
- const hasProject = projects.some((entry) => jsonAt(entry, "prd_path") === prdPath);
962
+ const matchPath = stage === "prd" ? prdPath : brainstormPath;
963
+ const hasProject = projects.some((entry) => jsonAt(entry, stage === "prd" ? "prd_path" : "brainstorm_path") === matchPath);
952
964
  if (hasProject) {
953
- boardDocument.projects = projects.map((entry) => jsonAt(entry, "prd_path") === prdPath && isRecord(entry)
965
+ boardDocument.projects = projects.map((entry) => jsonAt(entry, stage === "prd" ? "prd_path" : "brainstorm_path") === matchPath && isRecord(entry)
954
966
  ? { ...entry, owner: principal, updated_at: now }
955
967
  : entry);
956
968
  }
957
969
  else {
958
- boardDocument.projects = [...projects, {
959
- id: `proj-${project}`,
960
- title,
961
- description,
962
- status: "in_progress",
963
- owner: principal,
964
- prd_path: prdPath,
965
- created_at: now,
966
- updated_at: now,
967
- }];
970
+ const entry = {
971
+ id: `proj-${project}`,
972
+ title,
973
+ description,
974
+ status: stage === "prd" ? "in_progress" : "exploring",
975
+ owner: principal,
976
+ prd_path: stage === "prd" ? prdPath : null,
977
+ created_at: now,
978
+ updated_at: now,
979
+ };
980
+ if (stage === "brainstorm")
981
+ entry.brainstorm_path = brainstormPath;
982
+ boardDocument.projects = [...projects, entry];
968
983
  }
969
984
  boardDocument.updated_at = now;
970
985
  writeJsonAtomically(board, boardDocument);
971
- if (!isRecord(prd))
972
- throw new TypeError("Cannot update non-object prd.json");
973
- if (!isRecord(prd.metadata))
974
- prd.metadata = {};
975
- const metadata = prd.metadata;
976
- metadata.owner = principal;
977
- metadata.delegatedFrom = from;
978
- metadata.delegatedAt = now;
979
- writeJsonAtomically(prdAbs, prd);
986
+ if (stage === "prd") {
987
+ if (!isRecord(prd))
988
+ throw new TypeError("Cannot update non-object prd.json");
989
+ if (!isRecord(prd.metadata))
990
+ prd.metadata = {};
991
+ const metadata = prd.metadata;
992
+ metadata.owner = principal;
993
+ metadata.delegatedFrom = from;
994
+ metadata.delegatedAt = now;
995
+ writeJsonAtomically(prdAbs, prd);
996
+ }
997
+ else {
998
+ brainstormFrontmatterSet(brainstormAbs, "owner", principal);
999
+ brainstormFrontmatterSet(brainstormAbs, "delegated_from", from);
1000
+ brainstormFrontmatterSet(brainstormAbs, "delegated_at", now);
1001
+ }
980
1002
  if (commandExists("hq", context.cwd)) {
981
1003
  const sessionId = process.env.HQ_SESSION_ID || "delegate";
982
1004
  const mesh = subprocess("hq", ["mesh", "session", "note", "--enqueue", "--session-id", sessionId, "--seq", "1", "--harness", "claude-code", "--adapter-version", "1.0.0", "--company-slug", company, "--project", project, "--summary", `Delegated to ${display} (${principal}) — delegation ${delegationId}; ownership transferred`], context.cwd, { stdout: "inherit" });
@@ -993,12 +1015,17 @@ function runTransfer(args, context) {
993
1015
  const prefixesValue = jqFallback(document.vaultPrefixes, []);
994
1016
  const vaultPrefixes = Array.isArray(prefixesValue) ? prefixesValue : [];
995
1017
  const scope = vaultPrefixes.map((entry) => jqRawPath(entry, "prefix")).join(", ");
996
- fs.appendFileSync(journal, `\n## ${today} — Delegated to ${display}\n\n- Delegation: \`${delegationId}\`\n- From: ${from}\n- To: ${display} (${principal})\n- Transferred scope: ${scope}\n- Local board and PRD ownership updated; Work Mesh ownership is unconfirmed. Existing delegator access is unchanged.\n`);
1018
+ const ownershipLine = stage === "prd"
1019
+ ? "Local board and PRD ownership updated; Work Mesh ownership is unconfirmed. Existing delegator access is unchanged."
1020
+ : "Brainstorm-stage project (no PRD yet). Local board and brainstorm.md ownership updated; Work Mesh ownership is unconfirmed. Existing delegator access is unchanged.";
1021
+ fs.appendFileSync(journal, `\n## ${today} — Delegated to ${display}\n\n- Delegation: \`${delegationId}\`\n- From: ${from}\n- To: ${display} (${principal})\n- Transferred scope: ${scope}\n- ${ownershipLine}\n`);
997
1022
  }
998
1023
  document.ownershipTransferredAt = now;
999
1024
  document.workMeshOwnership = "unconfirmed";
1000
1025
  writeJsonAtomically(file, document);
1001
- writeOut(`${prefix} ownership of '${project}' transferred to ${display} (${principal}) — local board, PRD, and journal updated; Work Mesh ownership unconfirmed\n`);
1026
+ const sourceLabel = stage === "prd" ? "PRD" : "brainstorm.md";
1027
+ const stageLabel = stage === "prd" ? "" : " (brainstorm stage)";
1028
+ writeOut(`${prefix} ownership of '${project}'${stageLabel} transferred to ${display} (${principal}) — local board, ${sourceLabel}, and journal updated; Work Mesh ownership unconfirmed\n`);
1002
1029
  return 0;
1003
1030
  }
1004
1031
  function runNative(name, args, context) {
@@ -268,6 +268,7 @@ export const SCAFFOLD_ONLY_ASSETS = [
268
268
  { asset: "core/scripts/lib/session-scope-capability.sh", root: "live", summary: "Session scope-capability lib sourced by hq-session" },
269
269
  { asset: "core/scripts/lib/session-id.sh", root: "live", summary: "Session-id resolver lib sourced by hq-session" },
270
270
  { asset: "core/scripts/lib/hq-cli-floor.sh", root: "live", summary: "Shared CLI version-floor helper sourced by generated forwarders" },
271
+ { asset: "core/scripts/lib/delegate-brainstorm.sh", root: "live", summary: "Shared brainstorm-stage data helper sourced by delegate assets" },
271
272
  { asset: "core/scripts/lib/forwarder.sh", root: "live", summary: "Shared runtime sourced by generated scaffold forwarders" },
272
273
  { asset: "core/scripts/lib/work-mesh-enqueue.sh", root: "live", summary: "Work Mesh Live spool enqueue lib sourced by hooks" },
273
274
  { asset: "core/scripts/lib/work-mesh-live-hook.sh", root: "live", summary: "Work Mesh Live hook helpers sourced by rebind" },
@@ -0,0 +1,37 @@
1
+ import { type BotSessionScope } from "./paths.js";
2
+ export declare const DM_HISTORY_MAX_ENTRIES = 40;
3
+ export interface DmHistoryMessage {
4
+ role: "user" | "assistant";
5
+ from: string;
6
+ fromPersonUid?: string;
7
+ body: string;
8
+ at: string;
9
+ eventId?: string;
10
+ }
11
+ export interface DmHistoryFile {
12
+ v: 1;
13
+ messages: DmHistoryMessage[];
14
+ updatedAt: string;
15
+ /** Successful model turns in this DM. Used to inject history only after a session has existed. */
16
+ modelTurns?: number;
17
+ }
18
+ export declare function readDmHistory(dir: string, scope: BotSessionScope): DmHistoryFile;
19
+ export declare function writeDmHistory(dir: string, scope: BotSessionScope, history: DmHistoryFile): void;
20
+ /** Record a message, skipping duplicates by eventId and trimming the oldest past the cap. Pure. */
21
+ export declare function withDmHistoryMessage(history: DmHistoryFile, message: DmHistoryMessage, cap?: number): DmHistoryFile;
22
+ export declare function appendDmHistory(dir: string, scope: BotSessionScope, message: DmHistoryMessage, cap?: number): DmHistoryFile;
23
+ /** Count a successful model turn so a later new session knows there is thread to restore. */
24
+ export declare function markDmHistoryModelTurn(dir: string, scope: BotSessionScope, at: string): void;
25
+ export declare function dmHistoryHasModelThread(history: DmHistoryFile): boolean;
26
+ export declare function formatDmHistoryLines(messages: readonly DmHistoryMessage[], opts: {
27
+ excludeEventId?: string;
28
+ limit: number;
29
+ maxChars: number;
30
+ }): string[];
31
+ /** Prefix a DM turn with a "Recent messages:" block. Empty history leaves the prompt unchanged. */
32
+ export declare function withRecentDmHistory(prompt: string, messages: readonly DmHistoryMessage[], opts: {
33
+ excludeEventId?: string;
34
+ limit: number;
35
+ maxChars: number;
36
+ }): string;
37
+ //# sourceMappingURL=dm-history.d.ts.map