@indigoai-us/hq-cli 5.345.106 → 5.345.107

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/assets/scaffold/core/scripts/hq-task.mjs +259 -0
  3. package/assets/scaffold/core/scripts/hq-task.sh +31 -0
  4. package/assets/scaffold/core/scripts/pipeline-worker-table.sh +285 -0
  5. package/dist/command-catalog.generated.d.ts +135 -5
  6. package/dist/command-catalog.generated.js +161 -5
  7. package/dist/commands/auth.js +42 -0
  8. package/dist/commands/core-hq-task-fast.d.ts +4 -0
  9. package/dist/commands/core-hq-task-fast.js +11 -0
  10. package/dist/commands/core-hq-task-native.d.ts +20 -0
  11. package/dist/commands/core-hq-task-native.js +275 -0
  12. package/dist/commands/core-native-bucket-c-flag.d.ts +3 -0
  13. package/dist/commands/core-native-bucket-c-flag.js +6 -0
  14. package/dist/commands/core-native-family-flag.d.ts +0 -2
  15. package/dist/commands/core-native-family-flag.js +0 -4
  16. package/dist/commands/core-native-pipeline-flag.d.ts +3 -0
  17. package/dist/commands/core-native-pipeline-flag.js +6 -0
  18. package/dist/commands/core-ontology-native.js +24 -30
  19. package/dist/commands/core-pipeline-workers-command.d.ts +3 -0
  20. package/dist/commands/core-pipeline-workers-command.js +37 -0
  21. package/dist/commands/core-pipeline-workers-native.d.ts +13 -0
  22. package/dist/commands/core-pipeline-workers-native.js +361 -0
  23. package/dist/commands/core.js +12 -10
  24. package/dist/commands/lanes.js +32 -7
  25. package/dist/commands/outposts.d.ts +5 -0
  26. package/dist/commands/outposts.js +5 -0
  27. package/dist/fast-scaffold.d.ts +1 -1
  28. package/dist/fast-scaffold.js +13 -5
  29. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +56937 -64550
  30. package/dist/hq-cloud-sync-runner/package.json +1 -1
  31. package/dist/lib/daemon/remote-control-pools.d.ts +2 -2
  32. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  33. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  34. package/dist/lib/lanes/reconcile.js +4 -0
  35. package/dist/lib/lanes/remote-control-lane.d.ts +33 -0
  36. package/dist/lib/lanes/remote-control-lane.js +276 -0
  37. package/dist/lib/lanes/spawn.d.ts +5 -0
  38. package/dist/lib/lanes/spawn.js +65 -14
  39. package/dist/lib/lanes/store.js +7 -0
  40. package/dist/lib/lanes/types.d.ts +2 -0
  41. package/dist/lib/lanes/types.js +1 -1
  42. package/dist/lib/lanes/watcher.js +8 -1
  43. package/dist/utils/cognito-session.d.ts +12 -4
  44. package/dist/utils/cognito-session.js +42 -9
  45. package/dist/utils/token-keychain.d.ts +96 -0
  46. package/dist/utils/token-keychain.js +218 -0
  47. package/package.json +2 -2
  48. package/assets/scaffold/core/scripts/ontology-audience-grant.sh +0 -81
  49. package/assets/scaffold/core/scripts/ontology-brief.mjs +0 -93
  50. package/assets/scaffold/core/scripts/ontology-candidate.sh +0 -100
  51. package/assets/scaffold/core/scripts/ontology-garden.mjs +0 -327
  52. package/assets/scaffold/core/scripts/ontology-source-worklist.sh +0 -226
  53. package/assets/scaffold/core/scripts/source-yaml-validate.sh +0 -36
package/CHANGELOG.md CHANGED
@@ -2,7 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.107] — 2026-10-13
6
+ - Company Outposts: `hq outposts provision --company <slug>`, `hq outposts status --company <slug>` and `hq outposts jobs register` are available (hq-cloud 6.18.66). Only company admins can run them, the price and paying company are shown before anything is created, and nothing is provisioned without `--yes`.
7
+ - Agent processes on one box share one cached Cognito sign-in again instead of each signing in separately (hq-cloud 6.18.66 fix).
8
+ - Add native `hq core hq-task` behind the `core-native-bucket-c` family gate, with its pinned shell rollback assets.
9
+
10
+ - Remove the six bundled ontology shell fallbacks and pin their 189 shell cases as native differential fixtures.
11
+
12
+ - On macOS, hq now reads your HQ sign-in from the macOS Keychain copy kept
13
+ by the desktop app, and uses whichever of the Keychain and
14
+ `~/.hq/cognito-tokens.json` is newer. The first time, hq says that macOS
15
+ will ask once for access; choose Always Allow. If you deny it, hq keeps
16
+ using the file and won't ask again; run `hq auth keychain --retry` to
17
+ allow it later. Other platforms still use the file only.
18
+ - `hq lanes create --remote-control` now opens a Claude lane through Remote Control using account A for the remote session and a ready daemon pool from account B, C, or D for inference. Remote Control lanes disable `AskUserQuestion` and resume the same Claude session with `hq lanes resume`.
5
19
  ## [5.345.106] — 2026-10-12
20
+ - Add `hq core pipeline workers` as a native TypeScript worker-table command, with a bundled shell companion controlled by the `core-native-pipeline` flag.
21
+
6
22
  - On Windows, `hq core` converts Git Bash paths for commands that declare path operands. Direct calls use the same path rules as the former script wrappers.
7
23
  - `hq logout` now ends your session on the HQ server as well as on this
8
24
  device, so a copied token file stops working. If the server can't be
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ // hq-task.mjs — the global task list on an HQ board.
3
+ //
4
+ // Tasks live in `tasks[]` on a board file, not inside a project. Most tasks
5
+ // never belong to a project, and a `prd.json` is the wrong shape for one: it
6
+ // carries branchName, e2eTests, files and dependsOn, none of which mean
7
+ // anything for "renew the registration".
8
+ //
9
+ // personal/board.json the owner's own board (default)
10
+ // companies/<slug>/board.json a company board (--company <slug>)
11
+ //
12
+ // See core/knowledge/public/hq-core/goals-and-tasks-board.md for the concept.
13
+ //
14
+ // Node rather than python3: HQ's script layer must run on Windows machines
15
+ // where python3 is absent or is a Store-alias stub that fails every call
16
+ // (core/scripts/tests/hooks-no-python.test.sh).
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import process from "node:process";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const STATUSES = ["open", "blocked", "done"];
24
+
25
+ const USAGE = `hq-task — the global task list on an HQ board
26
+
27
+ Usage:
28
+ hq-task.sh list [--company <slug>] [--all] [--goal <objective-id>]
29
+ hq-task.sh add [--company <slug>] --title "<short title>"
30
+ [--description "<context>"]
31
+ [--criteria "<one done-criterion>"]...
32
+ [--contact "Name <email>|role"]...
33
+ [--goal <objective-id>] [--priority <1-3>]
34
+ hq-task.sh done [--company <slug>] --id T-002
35
+ hq-task.sh block [--company <slug>] --id T-002 --reason "<what is in the way>"
36
+ hq-task.sh reopen [--company <slug>] --id T-002
37
+ hq-task.sh goals [--company <slug>]
38
+
39
+ Default board: personal/board.json. With --company: companies/<slug>/board.json.`;
40
+
41
+ function hqRoot() {
42
+ if (process.env.HQ_ROOT) return process.env.HQ_ROOT;
43
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
44
+ }
45
+
46
+ function die(message) {
47
+ process.stderr.write(`${message}\n`);
48
+ process.exit(1);
49
+ }
50
+
51
+ function parseArgs(argv) {
52
+ const repeatable = new Set(["criteria", "contact"]);
53
+ const bare = new Set(["all"]);
54
+ const flags = {};
55
+ for (let i = 0; i < argv.length; i += 1) {
56
+ const token = argv[i];
57
+ if (!token.startsWith("--")) die(`Unexpected argument: ${token}\n\n${USAGE}`);
58
+ const key = token.slice(2);
59
+ if (bare.has(key)) {
60
+ flags[key] = true;
61
+ continue;
62
+ }
63
+ const value = argv[i + 1];
64
+ if (value === undefined || value.startsWith("--")) {
65
+ die(`Flag --${key} needs a value.\n\n${USAGE}`);
66
+ }
67
+ i += 1;
68
+ if (repeatable.has(key)) (flags[key] ||= []).push(value);
69
+ else flags[key] = value;
70
+ }
71
+ return flags;
72
+ }
73
+
74
+ // The owner's own scope is the `personal/` overlay at the HQ root, NOT
75
+ // `companies/personal/`. The personal vault's allowlist syncs `personal/`
76
+ // paths, so a board kept there is readable by an agent running under the
77
+ // owner's identity; the reserved company scope is absent from that vault.
78
+ function boardPath(flags) {
79
+ const root = hqRoot();
80
+ return flags.company
81
+ ? path.join(root, "companies", flags.company, "board.json")
82
+ : path.join(root, "personal", "board.json");
83
+ }
84
+
85
+ function load(flags) {
86
+ const file = boardPath(flags);
87
+ if (!fs.existsSync(file)) die(`No board at ${file}`);
88
+ const board = JSON.parse(fs.readFileSync(file, "utf8"));
89
+ board.tasks ||= [];
90
+ return { file, board };
91
+ }
92
+
93
+ function save(file, board) {
94
+ board.updated_at = new Date().toISOString().replace(/\.\d+Z$/, "Z");
95
+ fs.writeFileSync(file, `${JSON.stringify(board, null, 2)}\n`);
96
+ }
97
+
98
+ function nextId(tasks) {
99
+ let highest = 0;
100
+ for (const task of tasks) {
101
+ const match = /^T-(\d+)$/.exec(task.id || "");
102
+ if (match) highest = Math.max(highest, Number(match[1]));
103
+ }
104
+ return `T-${String(highest + 1).padStart(3, "0")}`;
105
+ }
106
+
107
+ // Accept "Name <email>|role" with the email and role both optional.
108
+ function parseContact(raw) {
109
+ let rest = raw;
110
+ let role = null;
111
+ const bar = rest.indexOf("|");
112
+ if (bar !== -1) {
113
+ role = rest.slice(bar + 1).trim();
114
+ rest = rest.slice(0, bar).trim();
115
+ }
116
+ let email = null;
117
+ const angle = /<([^>]+)>/.exec(rest);
118
+ if (angle) {
119
+ email = angle[1].trim();
120
+ rest = rest.slice(0, angle.index).trim();
121
+ }
122
+ const contact = { name: rest };
123
+ if (email) contact.email = email;
124
+ if (role) contact.role = role;
125
+ return contact;
126
+ }
127
+
128
+ const today = () => new Date().toISOString().slice(0, 10);
129
+
130
+ function findTask(board, id) {
131
+ const task = board.tasks.find((t) => t.id === id);
132
+ if (!task) die(`No task ${id} on that board.`);
133
+ return task;
134
+ }
135
+
136
+ function goalTitle(board, id) {
137
+ const objective = (board.objectives || []).find((o) => o.id === id);
138
+ return objective ? objective.title : null;
139
+ }
140
+
141
+ const MARK = { open: " ", blocked: "!", done: "x" };
142
+
143
+ function cmdList(flags) {
144
+ const { board } = load(flags);
145
+ let tasks = board.tasks.filter((t) => flags.all || t.status !== "done");
146
+ if (flags.goal) tasks = tasks.filter((t) => t.objective_id === flags.goal);
147
+ if (tasks.length === 0) {
148
+ process.stdout.write("No open tasks.\n");
149
+ return;
150
+ }
151
+ process.stdout.write(
152
+ `${flags.company || "personal"} — ${flags.all ? "all tasks" : "open tasks"} (${tasks.length})\n\n`,
153
+ );
154
+ tasks.sort(
155
+ (a, b) =>
156
+ (a.priority ?? 99) - (b.priority ?? 99) ||
157
+ String(a.id).localeCompare(String(b.id)),
158
+ );
159
+ for (const task of tasks) {
160
+ process.stdout.write(
161
+ ` [${MARK[task.status] ?? " "}] ${task.id} ${task.title || ""}\n`,
162
+ );
163
+ const goal = task.objective_id && goalTitle(board, task.objective_id);
164
+ if (goal) process.stdout.write(` goal: ${goal}\n`);
165
+ if (task.status === "blocked" && task.blockedReason) {
166
+ process.stdout.write(` blocked: ${task.blockedReason}\n`);
167
+ }
168
+ for (const contact of task.contacts || []) {
169
+ const trail = [contact.email, contact.phone].filter(Boolean).join(" · ");
170
+ process.stdout.write(
171
+ ` ${contact.name}${trail ? ` — ${trail}` : ""}\n`,
172
+ );
173
+ }
174
+ }
175
+ }
176
+
177
+ function cmdGoals(flags) {
178
+ const { board } = load(flags);
179
+ const objectives = board.objectives || [];
180
+ if (objectives.length === 0) {
181
+ process.stdout.write("No goals on that board.\n");
182
+ return;
183
+ }
184
+ for (const objective of objectives) {
185
+ const open = board.tasks.filter(
186
+ (t) => t.objective_id === objective.id && t.status !== "done",
187
+ ).length;
188
+ process.stdout.write(
189
+ `${objective.id} ${objective.title} (${open} open task${open === 1 ? "" : "s"})\n`,
190
+ );
191
+ for (const kr of objective.key_results || []) {
192
+ process.stdout.write(
193
+ ` ${kr.title} — ${kr.current ?? 0}/${kr.target} ${kr.unit || ""}\n`,
194
+ );
195
+ }
196
+ }
197
+ }
198
+
199
+ function cmdAdd(flags) {
200
+ if (!flags.title) die(`add needs --title.\n\n${USAGE}`);
201
+ const { file, board } = load(flags);
202
+ if (flags.goal && !goalTitle(board, flags.goal)) {
203
+ die(`No goal ${flags.goal} on that board. Run 'hq-task.sh goals' to list them.`);
204
+ }
205
+ const task = {
206
+ id: nextId(board.tasks),
207
+ title: flags.title,
208
+ description: flags.description || flags.title,
209
+ status: "open",
210
+ priority: flags.priority ? Number(flags.priority) : 2,
211
+ objective_id: flags.goal || null,
212
+ criteria: flags.criteria || [`${flags.title} — completed.`],
213
+ contacts: (flags.contact || []).map(parseContact),
214
+ createdAt: today(),
215
+ };
216
+ board.tasks.push(task);
217
+ save(file, board);
218
+ process.stdout.write(`Added ${task.id}: ${task.title}\n`);
219
+ }
220
+
221
+ function setStatus(flags, status, extra = {}) {
222
+ const { file, board } = load(flags);
223
+ if (!flags.id) die(`${status} needs --id.\n\n${USAGE}`);
224
+ const task = findTask(board, flags.id);
225
+ task.status = status;
226
+ if (status === "done") task.closedAt = today();
227
+ else delete task.closedAt;
228
+ if (status === "blocked") task.blockedReason = extra.reason || "";
229
+ else delete task.blockedReason;
230
+ save(file, board);
231
+ process.stdout.write(`${task.id} → ${status}: ${task.title}\n`);
232
+ }
233
+
234
+ const COMMANDS = {
235
+ list: cmdList,
236
+ goals: cmdGoals,
237
+ add: cmdAdd,
238
+ done: (f) => setStatus(f, "done"),
239
+ reopen: (f) => setStatus(f, "open"),
240
+ block: (f) => {
241
+ if (!f.reason) die(`block needs --reason.\n\n${USAGE}`);
242
+ setStatus(f, "blocked", { reason: f.reason });
243
+ },
244
+ };
245
+
246
+ function main() {
247
+ const [command, ...rest] = process.argv.slice(2);
248
+ if (!command || command === "--help" || command === "-h") {
249
+ process.stdout.write(`${USAGE}\n`);
250
+ process.exit(command ? 0 : 1);
251
+ }
252
+ const handler = COMMANDS[command];
253
+ if (!handler) die(`Unknown command: ${command}\n\n${USAGE}`);
254
+ handler(parseArgs(rest));
255
+ }
256
+
257
+ main();
258
+
259
+ export { STATUSES };
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bash
2
+ # hq-task.sh — add, list, and close tasks on a standing task-bucket project.
3
+ #
4
+ # A task bucket is an ordinary HQ project whose prd.json carries
5
+ # metadata.kind == "task_board". Its user stories are errands rather than
6
+ # software features, so they carry no branch, repo path, or test harness.
7
+ #
8
+ # Usage:
9
+ # hq-task.sh list [--company <slug>] [--project <name>] [--all]
10
+ # hq-task.sh add [--company <slug>] [--project <name>] \
11
+ # --title "<short title>" [--description "<context>"] \
12
+ # [--criteria "<one done-criterion>"]... \
13
+ # [--priority <n>] [--contact "Name <email>|role"]...
14
+ # hq-task.sh done [--company <slug>] [--project <name>] --id US-002
15
+ # hq-task.sh reopen [--company <slug>] [--project <name>] --id US-002
16
+ #
17
+ # Defaults: --company personal --project life-admin.
18
+ #
19
+ # See core/knowledge/public/hq-core/goals-and-tasks-board.md for the concept.
20
+ set -euo pipefail
21
+
22
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+ HQ_ROOT="${HQ_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
24
+ export HQ_ROOT
25
+
26
+ if ! command -v node >/dev/null 2>&1; then
27
+ echo "hq-task: node is required but was not found on PATH." >&2
28
+ exit 1
29
+ fi
30
+
31
+ exec node "$SCRIPT_DIR/hq-task.mjs" "$@"
@@ -0,0 +1,285 @@
1
+ #!/bin/sh
2
+ # hq-core: public
3
+ # pipeline-worker-table.sh — the per-worker engine/model table behind
4
+ # /run-project --pipeline.
5
+ #
6
+ # Each worker lane runs on its own engine and model. This script proposes that
7
+ # table from a prd.json and, once the owner has confirmed it, turns it into the
8
+ # launcher exports each lane starts with. It never reads an engine or model
9
+ # default from a settings file (orchestrator.yaml, settings.json, ...): the only
10
+ # seed is each worker's own worker.yaml, and anything that cannot be read off
11
+ # it is left empty and flagged for the owner to answer.
12
+ #
13
+ # Usage:
14
+ # pipeline-worker-table.sh [--workers-root <dir>]... [--overlay <file>] <prd.json>
15
+ # pipeline-worker-table.sh [--workers-root <dir>]... [--overlay <file>] --confirmed <table.tsv> <prd.json>
16
+ #
17
+ # PROPOSE MODE (no --confirmed) prints tab-separated lines:
18
+ #
19
+ # row <worker> <engine> <model> <effort> <status> <stories> <note>
20
+ # hint <worker> <story> <model>
21
+ # unclassified <story>
22
+ #
23
+ # status is `ok` or `needs-answer`. The lane set is the union of every
24
+ # story's worker sequence: one `row` per distinct worker that appears in any
25
+ # sequence (architect and qa-tester included), in first-seen order. A `hint`
26
+ # line is a story's model_hint, shown for the owner; a confirmed table pin wins
27
+ # over it (see CONFIRMED MODE).
28
+ #
29
+ # WORKER SEQUENCE (story -> workers, the full phase sequence in order), first
30
+ # match wins; pipeline-conductor.sh classify reads the same three sources:
31
+ # 1. --overlay <file> (the run's <state>/overlay.json, or the preflight plan):
32
+ # {id: {"worker_sequence": [...]}}, {id: [...]}, or
33
+ # {"ordered_stories": [{"id": .., "worker_sequence": [...]}]}.
34
+ # 2. worker_preference — a string or an array of worker ids, used as given.
35
+ # 3. Fallback, only when both are empty: the keyword table from
36
+ # .claude/skills/execute-task/SKILL.md step 3, matched case-insensitively
37
+ # against title + description + labels:
38
+ # schema_change (database|migration|schema|prisma|sql) -> database-dev
39
+ # ui_component (component|page|form|button|react|ui) -> frontend-dev
40
+ # api_development (endpoint|api|rest|graphql|route|service) -> backend-dev
41
+ # giving architect, the matched implementers (backend-dev also for a
42
+ # schema change), then qa-tester.
43
+ # A story matching none is printed as `unclassified`; no worker is guessed.
44
+ #
45
+ # UNKNOWN WORKERS: a worker with no worker.yaml under the worker roots gets an
46
+ # empty, needs-answer row noted "no worker.yaml", and a line on stderr naming
47
+ # the id, the roots searched and every known id. pipeline-conductor.sh classify
48
+ # refuses such a worker before launch.
49
+ #
50
+ # ENGINE SEED (from worker.yaml `execution:`):
51
+ # model only -> engine claude, model = execution.model
52
+ # codex_model only -> engine codex, model = execution.codex_model,
53
+ # effort = `--reasoning <x>` from execution.codex_flags
54
+ # both set -> ambiguous: engine and model empty, needs-answer, the
55
+ # candidates listed in the note column
56
+ # neither / no file -> engine and model empty, needs-answer
57
+ # worker.yaml has no grok field, so grok is never seeded; the owner chooses it
58
+ # in the confirmed table.
59
+ #
60
+ # CONFIRMED MODE: --confirmed <file> holds one lane per line,
61
+ # <worker> <engine> <model> <effort> (tabs or spaces; # comments allowed;
62
+ # effort may be omitted). engine must be claude, codex, or grok and model must
63
+ # be non-empty, or the script exits 2. Output per lane, using the pins in
64
+ # core/scripts/workflow-runner.mjs, plus HQ_CONDUCT_ENGINE=<engine> for
65
+ # every lane (a loop lane runs pipeline phase envelopes on that engine):
66
+ # claude -> HQ_WORKFLOW_CLAUDE_PLAN_MODEL, HQ_WORKFLOW_CLAUDE_EXEC_MODEL,
67
+ # HQ_WORKFLOW_CLAUDE_EFFORT
68
+ # codex|grok -> HQ_WORKFLOW_MODEL, HQ_WORKFLOW_EFFORT
69
+ # The effort export is omitted when effort is empty (runner default applies).
70
+ # A story's model_hint never overrides the lane: the lane's table pin wins.
71
+ # Each hinted story on a confirmed lane gets a `# lane <w> story <id>
72
+ # (model_hint)` block whose exports are commented out and that says "hint
73
+ # ignored, table pins <model>". A bare alias (opus, sonnet, haiku, fable) is
74
+ # never printed as a model.
75
+ #
76
+ # Worker roots: --workers-root (repeatable) or PIPELINE_WORKERS_ROOTS
77
+ # (colon-separated). Default: <hq>/core/workers, <hq>/companies/*/workers,
78
+ # <hq>/personal/workers. A worker is the directory named <id> holding worker.yaml.
79
+ #
80
+ # POSIX sh (dash-clean); JSON work is done by jq.
81
+
82
+ set -eu
83
+
84
+ HQ_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
85
+ NL='
86
+ '
87
+ TAB="$(printf '\t')"
88
+
89
+ die() { echo "pipeline-worker-table: $1" >&2; exit "${2:-1}"; }
90
+
91
+ ROOTS=""
92
+ CONFIRMED=""
93
+ OVERLAY=""
94
+ PRD=""
95
+ while [ $# -gt 0 ]; do
96
+ case "$1" in
97
+ --workers-root) [ $# -ge 2 ] || die "--workers-root needs a dir"; ROOTS="${ROOTS:+$ROOTS$NL}$2"; shift 2 ;;
98
+ --confirmed) [ $# -ge 2 ] || die "--confirmed needs a file"; CONFIRMED="$2"; shift 2 ;;
99
+ --overlay) [ $# -ge 2 ] || die "--overlay needs a file"; OVERLAY="$2"; shift 2 ;;
100
+ -h|--help) sed -n '3,/^# POSIX sh/p' "$0"; exit 0 ;;
101
+ -*) die "unknown flag: $1" ;;
102
+ *) [ -z "$PRD" ] || die "only one prd.json"; PRD="$1"; shift ;;
103
+ esac
104
+ done
105
+ [ -n "$PRD" ] || die "usage: pipeline-worker-table.sh [--workers-root <dir>] [--overlay <file>] [--confirmed <file>] <prd.json>"
106
+ [ -f "$PRD" ] || die "no such prd: $PRD"
107
+ command -v jq >/dev/null 2>&1 || die "jq is required"
108
+ jq -e '.userStories | type == "array"' "$PRD" >/dev/null 2>&1 || die "not a prd (no userStories array): $PRD"
109
+ if [ -n "$OVERLAY" ]; then
110
+ [ -f "$OVERLAY" ] || die "no such overlay: $OVERLAY"
111
+ jq -e 'type == "object"' "$OVERLAY" >/dev/null 2>&1 || die "overlay is not a JSON object: $OVERLAY"
112
+ fi
113
+
114
+ if [ -z "$ROOTS" ]; then
115
+ if [ -n "${PIPELINE_WORKERS_ROOTS:-}" ]; then
116
+ ROOTS="$(printf '%s\n' "$PIPELINE_WORKERS_ROOTS" | tr ':' '\n')"
117
+ else
118
+ ROOTS="$HQ_ROOT/core/workers"
119
+ for d in "$HQ_ROOT"/companies/*/workers; do [ -d "$d" ] && ROOTS="$ROOTS$NL$d"; done
120
+ ROOTS="$ROOTS$NL$HQ_ROOT/personal/workers"
121
+ fi
122
+ fi
123
+
124
+ # SEQS: one line per story, "<id><TAB><w1> <w2> ..." (empty worker list when
125
+ # unclassified), in prd order. The one place the sequence is derived.
126
+ SEQS="$(jq -r --slurpfile ov "${OVERLAY:-/dev/null}" '
127
+ def seq: if type == "array" then [.[] | select(type == "string" and length > 0)]
128
+ elif type == "string" then [split(",")[] | gsub("^\\s+|\\s+$"; "") | select(length > 0)]
129
+ elif type == "object" then (.worker_sequence // [] | seq) else [] end;
130
+ ($ov[0] // {}) as $o
131
+ | (if ($o.ordered_stories | type) == "array"
132
+ then [$o.ordered_stories[] | select(type == "object" and (.id | type) == "string") | {key: .id, value: (.worker_sequence // [] | seq)}] | from_entries
133
+ else ($o | with_entries(.value |= seq)) end) as $m
134
+ | .userStories[]
135
+ | . as $s
136
+ | ([$s.title // "", $s.description // "", (($s.labels // []) | join(" "))] | join(" ") | ascii_downcase) as $t
137
+ | ($m[$s.id // ""] // []) as $ovs
138
+ | (($s.worker_preference // []) | seq) as $pref
139
+ | (if ($ovs | length) > 0 then $ovs
140
+ elif ($pref | length) > 0 then $pref
141
+ else
142
+ ($t | test("\\b(database|migration|schema|prisma|sql)\\b")) as $schema
143
+ | ($t | test("\\b(component|page|form|button|react|ui)\\b")) as $ui
144
+ | ($t | test("\\b(endpoint|api|rest|graphql|route|service)\\b")) as $api
145
+ | if ($schema or $ui or $api) | not then []
146
+ else ["architect"] + (if $schema then ["database-dev"] else [] end)
147
+ + (if ($api or $schema) then ["backend-dev"] else [] end)
148
+ + (if $ui then ["frontend-dev"] else [] end) + ["qa-tester"] end
149
+ end) as $w
150
+ | "\($s.id // "")\t\($w | join(" "))"' "$PRD")"
151
+
152
+ # story_has <story id> <worker> -> 0 when the worker is in that story's sequence
153
+ story_has() {
154
+ printf '%s\n' "$SEQS" | while IFS="$TAB" read -r sid ws; do
155
+ [ "$sid" = "$1" ] || continue
156
+ case " $ws " in *" $2 "*) echo yes ;; esac
157
+ done | grep -q yes
158
+ }
159
+
160
+ find_worker_yaml() {
161
+ printf '%s\n' "$ROOTS" | while IFS= read -r r; do
162
+ [ -n "$r" ] && [ -d "$r" ] || continue
163
+ hit="$(find "$r" -type f -name worker.yaml -path "*/$1/worker.yaml" 2>/dev/null | head -n 1)"
164
+ if [ -n "$hit" ]; then echo "$hit"; break; fi
165
+ done
166
+ }
167
+
168
+ known_workers() {
169
+ printf '%s\n' "$ROOTS" | while IFS= read -r r; do
170
+ [ -n "$r" ] && [ -d "$r" ] || continue
171
+ find "$r" -type f -name worker.yaml 2>/dev/null
172
+ done | while IFS= read -r y; do basename "$(dirname "$y")"; done | sort -u | tr '\n' ' ' | sed 's/ $//'
173
+ }
174
+
175
+ # exec_field <worker.yaml> <key> -> value of execution.<key>, quotes and
176
+ # trailing comments stripped; empty when absent.
177
+ exec_field() {
178
+ awk -v key="$2" '
179
+ /^[^[:space:]#]/ { inexec = ($0 ~ /^execution:[[:space:]]*$/); next }
180
+ inexec && $0 ~ "^[[:space:]]+" key ":" {
181
+ v = $0; sub("^[[:space:]]+" key ":[[:space:]]*", "", v)
182
+ sub(/[[:space:]]+#.*$/, "", v); sub(/[[:space:]]+$/, "", v)
183
+ gsub(/^["'\'']|["'\'']$/, "", v); print v; exit
184
+ }' "$1"
185
+ }
186
+
187
+ N="$(jq '.userStories | length' "$PRD")"
188
+
189
+ # ---- confirmed mode -------------------------------------------------------
190
+ # sq <value> -> value as one single-quoted shell word (embedded ' escaped), so
191
+ # a hostile model/effort string in the table or prd cannot break out of the
192
+ # export line when it is eval'd or sourced.
193
+ sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
194
+
195
+ emit_exports() { # engine model effort [prefix]
196
+ printf '%sexport HQ_CONDUCT_ENGINE=%s\n' "${4:-}" "$(sq "$1")"
197
+ case "$1" in
198
+ claude)
199
+ printf '%sexport HQ_WORKFLOW_CLAUDE_PLAN_MODEL=%s\n' "${4:-}" "$(sq "$2")"
200
+ printf '%sexport HQ_WORKFLOW_CLAUDE_EXEC_MODEL=%s\n' "${4:-}" "$(sq "$2")"
201
+ [ -z "$3" ] || printf '%sexport HQ_WORKFLOW_CLAUDE_EFFORT=%s\n' "${4:-}" "$(sq "$3")" ;;
202
+ codex|grok)
203
+ printf '%sexport HQ_WORKFLOW_MODEL=%s\n' "${4:-}" "$(sq "$2")"
204
+ [ -z "$3" ] || printf '%sexport HQ_WORKFLOW_EFFORT=%s\n' "${4:-}" "$(sq "$3")" ;;
205
+ esac
206
+ }
207
+
208
+ is_bare_alias() { case "$1" in opus|sonnet|haiku|fable|Opus|Sonnet|Haiku|Fable) return 0 ;; esac; return 1; }
209
+
210
+ if [ -n "$CONFIRMED" ]; then
211
+ [ -f "$CONFIRMED" ] || die "no such confirmed table: $CONFIRMED"
212
+ while read -r w engine model effort _rest || [ -n "${w:-}" ]; do
213
+ case "$w" in ''|'#'*|worker) continue ;; esac
214
+ case "${engine:-}" in claude|codex|grok) ;; *) die "lane $w: engine must be claude, codex, or grok (got '${engine:-}')" 2 ;; esac
215
+ [ -n "${model:-}" ] || die "lane $w: model is empty — answer it before launch" 2
216
+ echo "# lane $w ($engine)"
217
+ emit_exports "$engine" "$model" "${effort:-}"
218
+ i=0
219
+ while [ "$i" -lt "$N" ]; do
220
+ hint="$(jq -r --argjson i "$i" '.userStories[$i].model_hint // ""' "$PRD")"
221
+ sid="$(jq -r --argjson i "$i" '.userStories[$i].id' "$PRD")"
222
+ if [ -n "$hint" ] && story_has "$sid" "$w"; then
223
+ if is_bare_alias "$hint"; then
224
+ echo "# lane $w story $sid (model_hint) bare alias ignored, table pins $model"
225
+ else
226
+ echo "# lane $w story $sid (model_hint) hint ignored, table pins $model"
227
+ emit_exports "$engine" "$hint" "${effort:-}" "# "
228
+ fi
229
+ fi
230
+ i=$((i + 1))
231
+ done
232
+ w=""; engine=""; model=""; effort=""
233
+ done <"$CONFIRMED"
234
+ exit 0
235
+ fi
236
+
237
+ # ---- propose mode ---------------------------------------------------------
238
+ # WORKERS: distinct workers, first-seen order, one per line.
239
+ # STORIES_OF <worker>: the story ids whose sequence holds it, space-joined.
240
+ WORKERS="$(printf '%s\n' "$SEQS" | while IFS="$TAB" read -r sid ws; do
241
+ for w in $ws; do echo "$w"; done
242
+ done | awk '!seen[$0]++')"
243
+
244
+ printf '%s\n' "$SEQS" | while IFS="$TAB" read -r sid ws; do
245
+ [ -n "$ws" ] || printf 'unclassified\t%s\n' "$sid"
246
+ done
247
+
248
+ stories_of() {
249
+ printf '%s\n' "$SEQS" | while IFS="$TAB" read -r sid ws; do
250
+ case " $ws " in *" $1 "*) printf '%s\n' "$sid" ;; esac
251
+ done | tr '\n' ' ' | sed 's/ $//'
252
+ }
253
+
254
+ KNOWN=""
255
+ printf '%s\n' "$WORKERS" | while IFS= read -r w; do
256
+ [ -n "$w" ] || continue
257
+ engine=""; model=""; effort=""; status="needs-answer"; note=""
258
+ y="$(find_worker_yaml "$w")"
259
+ if [ -z "$y" ]; then
260
+ note="no worker.yaml"
261
+ [ -n "$KNOWN" ] || KNOWN="$(known_workers)"
262
+ echo "pipeline-worker-table: unknown worker $w: no worker.yaml under $(printf '%s\n' "$ROOTS" | tr '\n' ' ' | sed 's/ $//'); known: ${KNOWN:-none}" >&2
263
+ else
264
+ cm="$(exec_field "$y" model)"; xm="$(exec_field "$y" codex_model)"
265
+ if [ -n "$cm" ] && [ -n "$xm" ]; then
266
+ note="ambiguous: claude:$cm codex:$xm"
267
+ elif [ -n "$cm" ]; then
268
+ engine=claude; model="$cm"; status=ok
269
+ elif [ -n "$xm" ]; then
270
+ engine=codex; model="$xm"; status=ok
271
+ effort="$(exec_field "$y" codex_flags | sed -n 's/.*--reasoning[= ]\([A-Za-z]*\).*/\1/p')"
272
+ else
273
+ note="no execution.model or execution.codex_model"
274
+ fi
275
+ fi
276
+ printf 'row\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$w" "$engine" "$model" "$effort" "$status" "$(stories_of "$w")" "$note"
277
+ done
278
+
279
+ # hint lines: each hinted story in prd order, one per worker in its sequence
280
+ jq -r '.userStories[] | select((.model_hint // "") != "") | "\(.id)\t\(.model_hint)"' "$PRD" |
281
+ while IFS="$TAB" read -r sid hint; do
282
+ ws="$(printf '%s\n' "$SEQS" | while IFS="$TAB" read -r s2 w2; do if [ "$s2" = "$sid" ]; then printf "%s\n" "$w2"; fi; done)"
283
+ for w in $ws; do printf 'hint\t%s\t%s\t%s\n' "$w" "$sid" "$hint"; done
284
+ done
285
+ exit 0