@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.
- package/CHANGELOG.md +3 -0
- package/assets/scaffold/core/scripts/hq-delegate-bundle.sh +107 -21
- package/assets/scaffold/core/scripts/hq-delegate-publish.sh +36 -5
- package/assets/scaffold/core/scripts/hq-delegate-send.sh +18 -3
- package/assets/scaffold/core/scripts/hq-delegate-transfer.sh +74 -26
- package/assets/scaffold/core/scripts/hq-delegate-verify.sh +10 -1
- package/assets/scaffold/core/scripts/lib/delegate-brainstorm.sh +120 -0
- package/dist/commands/core-delegate-brainstorm.d.ts +9 -0
- package/dist/commands/core-delegate-brainstorm.js +161 -0
- package/dist/commands/core-delegate-w1b.js +134 -40
- package/dist/commands/core-delegate.d.ts +2 -0
- package/dist/commands/core-delegate.js +56 -29
- package/dist/commands/core.js +1 -0
- package/package.json +1 -1
|
@@ -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\
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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", []))
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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\
|
|
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
|
-
|
|
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;
|