@catalyst-cloud/catalyst-skills 0.1.1 → 0.2.1
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 +8 -0
- package/README.md +109 -53
- package/bin/catalyst-skills.js +42 -2
- package/dist/args.js +242 -0
- package/dist/ask.js +137 -0
- package/dist/cli.js +167 -279
- package/dist/config.js +106 -0
- package/dist/contract-types.js +4 -0
- package/dist/contract.js +178 -0
- package/dist/detach.js +10 -0
- package/dist/errors.js +27 -0
- package/dist/execution.js +308 -0
- package/dist/http.js +175 -0
- package/dist/prompt.js +35 -0
- package/dist/query.js +225 -0
- package/dist/ready.js +123 -0
- package/dist/replica.js +318 -0
- package/dist/sdk.js +26 -0
- package/dist/skill-shape.js +179 -0
- package/dist/skills.js +74 -0
- package/dist/ts-deps-loader.js +68 -0
- package/dist/watch/consumer.js +141 -0
- package/dist/watch/cursor-file.js +62 -0
- package/dist/watch.js +173 -0
- package/dist/write.js +224 -0
- package/package.json +12 -7
- package/skills/catalyst-github/SKILL.md +35 -0
- package/skills/catalyst-github/agents/openai.yaml +6 -0
- package/skills/catalyst-github/agents/portability.yaml +4 -0
- package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
- package/skills/catalyst-github/scripts/lib/cli.mjs +102 -0
- package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
- package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
- package/skills/catalyst-linear/SKILL.md +43 -0
- package/skills/catalyst-linear/agents/openai.yaml +6 -0
- package/skills/catalyst-linear/agents/portability.yaml +5 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +50 -0
- package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
- package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
- package/skills/catalyst-linear/scripts/comment.mjs +59 -0
- package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
- package/skills/catalyst-linear/scripts/label.mjs +48 -0
- package/skills/catalyst-linear/scripts/lib/cli.mjs +163 -0
- package/skills/catalyst-linear/scripts/move.mjs +41 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
- package/skills/catalyst-linear/scripts/search.mjs +49 -0
- package/skills/catalyst-setup/SKILL.md +32 -0
- package/skills/catalyst-setup/agents/openai.yaml +6 -0
- package/skills/catalyst-setup/agents/portability.yaml +4 -0
- package/skills/catalyst-setup/references/what-each-check-means.md +61 -0
- package/skills/catalyst-setup/scripts/check.mjs +75 -0
- package/skills/catalyst-setup/scripts/lib/cli.mjs +102 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
- package/skills/connect-me/SKILL.md +55 -0
- package/skills/connect-me/agents/openai.yaml +6 -0
- package/skills/connect-me/agents/portability.yaml +5 -0
- package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
- package/skills/connect-me/scripts/lib/cli.mjs +184 -0
- package/skills/connect-me/scripts/verify-connection.mjs +68 -0
- package/skills/how-catalyst-works/SKILL.md +42 -0
- package/skills/how-catalyst-works/agents/openai.yaml +6 -0
- package/skills/how-catalyst-works/agents/portability.yaml +4 -0
- package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
- package/skills/how-catalyst-works/references/stages-and-mapping.md +54 -0
- package/skills/how-catalyst-works/references/the-ladder.md +41 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
- package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +163 -0
- package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
- package/skills/how-catalyst-works/scripts/whats-running.mjs +66 -0
- package/skills/run-this-project/SKILL.md +45 -0
- package/skills/run-this-project/agents/openai.yaml +6 -0
- package/skills/run-this-project/agents/portability.yaml +5 -0
- package/skills/run-this-project/assets/stall-policy.json +15 -0
- package/skills/run-this-project/references/making-work-ready.md +60 -0
- package/skills/run-this-project/references/reacting-to-events.md +76 -0
- package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
- package/skills/run-this-project/scripts/lib/cli.mjs +184 -0
- package/skills/run-this-project/scripts/make-ready.mjs +64 -0
- package/skills/run-this-project/scripts/scope-status.mjs +0 -0
- package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
- package/skills/what-needs-me/SKILL.md +41 -0
- package/skills/what-needs-me/agents/openai.yaml +6 -0
- package/skills/what-needs-me/agents/portability.yaml +5 -0
- package/skills/what-needs-me/references/raising-a-decision.md +41 -0
- package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
- package/skills/what-needs-me/references/settling-an-answer.md +37 -0
- package/skills/what-needs-me/scripts/inbox.mjs +47 -0
- package/skills/what-needs-me/scripts/lib/cli.mjs +134 -0
- package/skills/what-needs-me/scripts/raise.mjs +53 -0
- package/skills/what-needs-me/scripts/settle.mjs +73 -0
- package/skills/whats-happening/SKILL.md +43 -0
- package/skills/whats-happening/agents/openai.yaml +6 -0
- package/skills/whats-happening/agents/portability.yaml +4 -0
- package/skills/whats-happening/assets/status-reply.json +77 -0
- package/skills/whats-happening/references/reading-the-board.md +43 -0
- package/skills/whats-happening/references/reprioritising.md +37 -0
- package/skills/whats-happening/references/routing-work.md +36 -0
- package/skills/whats-happening/references/status-reply.md +34 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +57 -0
- package/skills/whats-happening/scripts/explain.mjs +28 -0
- package/skills/whats-happening/scripts/lib/cli.mjs +134 -0
- package/skills/whats-happening/scripts/snapshot.mjs +145 -0
- package/skills/ask/SKILL.md +0 -37
- package/skills/concierge/SKILL.md +0 -41
- package/skills/join/SKILL.md +0 -30
- package/skills/linearis/SKILL.md +0 -40
- package/skills/setup/SKILL.md +0 -27
- package/skills/steward/SKILL.md +0 -30
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// explain.mjs — "why is this ticket stuck?" in one paragraph. A thin wrapper over
|
|
3
|
+
// `catalyst-skills explain <ticket>`, which reads the cloud's eligibility explainer for the ticket's
|
|
4
|
+
// team and translates the exclusion reason, the failure block and any advisories into plain English.
|
|
5
|
+
import { mustRun, parseFlags, printHelp } from "./lib/cli.mjs";
|
|
6
|
+
|
|
7
|
+
const SPEC = {
|
|
8
|
+
json: { value: false, help: "print the raw eligibility row and the explanation as JSON" },
|
|
9
|
+
history: { value: false, help: "ask for the per-ticket execution history (the CLI says when a key cannot see it yet)" },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const { help, flags, positionals } = parseFlags(process.argv.slice(2), SPEC);
|
|
13
|
+
const ticket = positionals[0];
|
|
14
|
+
if (help || !ticket) {
|
|
15
|
+
printHelp("node scripts/explain.mjs <ticket> [--json] [--history] [--help]", SPEC, [
|
|
16
|
+
"The ticket is its identifier (the KEY-123 shape). The explanation names the queue position, the status,",
|
|
17
|
+
"the reason nothing is offered, the last failure, and what releases it; an unknown reason is printed as the",
|
|
18
|
+
"cloud spelled it. Read references/why-is-it-stuck.md to turn the reason into the next action.",
|
|
19
|
+
]);
|
|
20
|
+
process.exit(help ? 0 : 1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const args = ["explain", ticket];
|
|
24
|
+
if (flags.history) args.push("--history");
|
|
25
|
+
if (flags.json) args.push("--json");
|
|
26
|
+
const r = mustRun(args);
|
|
27
|
+
process.stdout.write(r.stdout.endsWith("\n") ? r.stdout : `${r.stdout}\n`);
|
|
28
|
+
process.exit(0);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/cli.mjs — the one way a skill script reaches Catalyst Cloud: spawn the catalyst-skills CLI.
|
|
3
|
+
// The CLI holds the SDK and the account key; this file holds neither. It reads customer.json only
|
|
4
|
+
// to learn where the CLI lives, and it exits 2 with one line when the machine is not connected.
|
|
5
|
+
// Run any script beside this one with --help; this file is a library and is never run directly.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export const PACKAGE = "@catalyst-cloud/catalyst-skills";
|
|
11
|
+
export const NOT_CONFIGURED_EXIT = 2;
|
|
12
|
+
|
|
13
|
+
export function configPath() {
|
|
14
|
+
const home = process.env.CATALYST_SKILLS_HOME ?? process.env.HOME ?? "/";
|
|
15
|
+
return join(home, ".config", "catalyst-cloud", "customer.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The connected-machine config, or exit 2 with the one line that says how to connect. */
|
|
19
|
+
export function loadConfig() {
|
|
20
|
+
const path = configPath();
|
|
21
|
+
if (!existsSync(path)) notConfigured(`no config at ${path}`);
|
|
22
|
+
try {
|
|
23
|
+
const cfg = JSON.parse(readFileSync(path, "utf8"));
|
|
24
|
+
if (typeof cfg !== "object" || cfg === null || typeof cfg.key !== "string") notConfigured(`config at ${path} has no key`);
|
|
25
|
+
return cfg;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
if (err && err.exitCode === NOT_CONFIGURED_EXIT) throw err;
|
|
28
|
+
notConfigured(`config at ${path} is unreadable`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function notConfigured(why) {
|
|
33
|
+
process.stderr.write(`not connected (${why}) — run: CATALYST_CLOUD_TOKEN=<account key> npx ${PACKAGE} login\n`);
|
|
34
|
+
process.exit(NOT_CONFIGURED_EXIT);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run one catalyst-skills verb and return {code, stdout, stderr}. Uses the CLI path the login
|
|
39
|
+
* recorded when it still exists, else `npx <package>`. A CLI exit of 2 with a "not joined" or
|
|
40
|
+
* "not connected" line is turned into this script's own exit 2, so every caller sees one contract.
|
|
41
|
+
*/
|
|
42
|
+
export function runCli(args, opts = {}) {
|
|
43
|
+
const cfg = loadConfig();
|
|
44
|
+
const useRecorded = typeof cfg.cliPath === "string" && existsSync(cfg.cliPath);
|
|
45
|
+
const cmd = useRecorded ? process.execPath : "npx";
|
|
46
|
+
const argv = useRecorded ? [cfg.cliPath, ...args] : [PACKAGE, ...args];
|
|
47
|
+
const res = spawnSync(cmd, argv, {
|
|
48
|
+
input: opts.stdin,
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
51
|
+
shell: !useRecorded && process.platform === "win32",
|
|
52
|
+
env: process.env,
|
|
53
|
+
});
|
|
54
|
+
if (res.error) {
|
|
55
|
+
process.stderr.write(`could not run ${cmd}: ${res.error.message}\n`);
|
|
56
|
+
process.exit(NOT_CONFIGURED_EXIT);
|
|
57
|
+
}
|
|
58
|
+
const stdout = res.stdout ?? "";
|
|
59
|
+
const stderr = res.stderr ?? "";
|
|
60
|
+
if (res.status === NOT_CONFIGURED_EXIT && /not (joined|connected|configured)/i.test(stderr)) {
|
|
61
|
+
process.stderr.write(stderr);
|
|
62
|
+
process.exit(NOT_CONFIGURED_EXIT);
|
|
63
|
+
}
|
|
64
|
+
return { code: res.status ?? 1, stdout, stderr };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Run a verb and require success; on failure print the CLI's own stderr and exit with its code (or 1). */
|
|
68
|
+
export function mustRun(args, opts = {}) {
|
|
69
|
+
const r = runCli(args, opts);
|
|
70
|
+
if (r.code !== 0) {
|
|
71
|
+
if (r.stderr) process.stderr.write(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
|
|
72
|
+
if (r.stdout && !opts.quiet) process.stdout.write(r.stdout.endsWith("\n") ? r.stdout : `${r.stdout}\n`);
|
|
73
|
+
process.exit(r.code === 0 ? 1 : r.code);
|
|
74
|
+
}
|
|
75
|
+
return r;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parse the CLI's --json stdout; a non-JSON answer is a failed check, exit 1. */
|
|
79
|
+
export function parseJson(stdout, what = "output") {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(stdout.trim());
|
|
82
|
+
} catch {
|
|
83
|
+
process.stderr.write(`could not parse ${what} as JSON: ${stdout.trim().slice(0, 200)}\n`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Minimal flag parsing for the scripts: --name value, --name=value, --flag, repeated flags collect. */
|
|
89
|
+
export function parseFlags(argv, spec) {
|
|
90
|
+
const flags = {};
|
|
91
|
+
const positionals = [];
|
|
92
|
+
for (let i = 0; i < argv.length; i++) {
|
|
93
|
+
const a = argv[i];
|
|
94
|
+
if (a === "--help" || a === "-h") return { help: true, flags, positionals };
|
|
95
|
+
if (a.startsWith("--")) {
|
|
96
|
+
let name = a.slice(2);
|
|
97
|
+
let value;
|
|
98
|
+
const eq = name.indexOf("=");
|
|
99
|
+
if (eq !== -1) {
|
|
100
|
+
value = name.slice(eq + 1);
|
|
101
|
+
name = name.slice(0, eq);
|
|
102
|
+
}
|
|
103
|
+
const s = spec[name];
|
|
104
|
+
if (!s) {
|
|
105
|
+
process.stderr.write(`unknown option --${name} (try --help)\n`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
if (s.value) {
|
|
109
|
+
if (value === undefined) value = argv[++i];
|
|
110
|
+
if (value === undefined || value === "") {
|
|
111
|
+
process.stderr.write(`--${name} needs a value\n`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
if (s.repeat) (flags[name] ??= []).push(value);
|
|
115
|
+
else flags[name] = value;
|
|
116
|
+
} else flags[name] = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
positionals.push(a);
|
|
120
|
+
}
|
|
121
|
+
return { help: false, flags, positionals };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function printHelp(usage, spec, notes = []) {
|
|
125
|
+
const lines = [`Usage: ${usage}`, ""];
|
|
126
|
+
const names = Object.keys(spec);
|
|
127
|
+
if (names.length) {
|
|
128
|
+
lines.push("Options:");
|
|
129
|
+
for (const n of names) lines.push(` --${n}${spec[n].value ? " <value>" : ""} ${spec[n].help}`);
|
|
130
|
+
lines.push("");
|
|
131
|
+
}
|
|
132
|
+
lines.push(...notes, "", "Exit codes: 0 ok · 1 the check failed or the arguments were wrong · 2 this machine is not connected to a tenant");
|
|
133
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
134
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// snapshot.mjs — one call that answers "where are we?": the tenant contract (trimmed to what a
|
|
3
|
+
// status reply needs), what is running, what is queued, what is waiting on a human, and whether the
|
|
4
|
+
// local replica is fresh. Prints ONE JSON document on stdout and the source line on stderr.
|
|
5
|
+
import { mustRun, parseFlags, parseJson, printHelp, runCli } from "./lib/cli.mjs";
|
|
6
|
+
|
|
7
|
+
const SPEC = {
|
|
8
|
+
team: { value: true, help: "limit the queue and the board to this team key" },
|
|
9
|
+
board: { value: false, help: "also include open tickets grouped by stage, in the contract's slot order" },
|
|
10
|
+
limit: { value: true, help: "board: max tickets to read (default 200)" },
|
|
11
|
+
"full-contract": { value: false, help: "include the whole contract instead of the trimmed tenant block" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const { help, flags } = parseFlags(process.argv.slice(2), SPEC);
|
|
15
|
+
if (help) {
|
|
16
|
+
printHelp("node scripts/snapshot.mjs [--team K] [--board] [--limit N] [--full-contract] [--help]", SPEC, [
|
|
17
|
+
"Runs, in order: catalyst-skills contract, running, queue, ask list, replica status (and query issues with --board).",
|
|
18
|
+
"Output: one JSON document {takenAt, source, tenant, running, queue, waitingOnHuman, board?, errors?}.",
|
|
19
|
+
"A section the cloud refused is reported under errors and the rest still prints; exit 1 in that case.",
|
|
20
|
+
"Every tenant fact (stage names, thresholds, teams) comes from the contract in this output, never from prose.",
|
|
21
|
+
]);
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const errors = {};
|
|
26
|
+
const section = (name, args) => {
|
|
27
|
+
const r = runCli(args);
|
|
28
|
+
if (r.code !== 0) {
|
|
29
|
+
errors[name] = (r.stderr || r.stdout || `exit ${r.code}`).trim().split("\n").at(-1);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return parseJson(r.stdout, name);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const contract = parseJson(mustRun(["contract", "--json"], { quiet: true }).stdout, "contract");
|
|
36
|
+
// `replica status` exits 0 fresh, 1 stale, 3 absent — all three are verdicts with JSON on stdout,
|
|
37
|
+
// not failures; only a non-JSON answer is an error.
|
|
38
|
+
const replica = (() => {
|
|
39
|
+
const r = runCli(["replica", "status", "--json"]);
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(r.stdout.trim());
|
|
42
|
+
} catch {
|
|
43
|
+
errors.replica = (r.stderr || r.stdout || `exit ${r.code}`).trim().split("\n").at(-1);
|
|
44
|
+
return { verdict: "unknown", cursor: null, heartbeatAgeMs: null };
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
47
|
+
const running = section("running", ["running", "--json"]);
|
|
48
|
+
const queue = section("queue", flags.team ? ["queue", "--team", flags.team, "--json"] : ["queue", "--json"]);
|
|
49
|
+
const asks = section("waitingOnHuman", ["ask", "list", "--json"]);
|
|
50
|
+
|
|
51
|
+
const out = {
|
|
52
|
+
takenAt: new Date().toISOString(),
|
|
53
|
+
source: {
|
|
54
|
+
reads: "api",
|
|
55
|
+
replica: replica.verdict,
|
|
56
|
+
replicaCursor: replica.cursor ?? null,
|
|
57
|
+
replicaHeartbeatAgeMs: replica.heartbeatAgeMs ?? null,
|
|
58
|
+
},
|
|
59
|
+
tenant: flags["full-contract"] ? contract : trimContract(contract, flags.team),
|
|
60
|
+
running,
|
|
61
|
+
queue,
|
|
62
|
+
waitingOnHuman: asks,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (flags.board) {
|
|
66
|
+
const args = ["query", "issues", "--limit", String(flags.limit ?? 200), "--json"];
|
|
67
|
+
if (flags.team) args.push("--team", flags.team);
|
|
68
|
+
const r = runCli(args);
|
|
69
|
+
const sourceLine = (r.stderr.match(/^source: .*$/m) ?? [null])[0];
|
|
70
|
+
if (r.code !== 0) errors.board = (r.stderr || `exit ${r.code}`).trim().split("\n").at(-1);
|
|
71
|
+
else {
|
|
72
|
+
const rows = parseJson(r.stdout, "issues");
|
|
73
|
+
out.board = groupByStage(rows, contract, flags.team);
|
|
74
|
+
if (sourceLine) out.source.board = sourceLine.replace(/^source: /, "");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (Object.keys(errors).length) out.errors = errors;
|
|
79
|
+
process.stderr.write(`source: api for running/queue/asks; replica ${replica.verdict}${replica.cursor != null ? ` (cursor ${replica.cursor})` : ""}${out.source.board ? `; board from ${out.source.board}` : ""}\n`);
|
|
80
|
+
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
81
|
+
process.exit(Object.keys(errors).length ? 1 : 0);
|
|
82
|
+
|
|
83
|
+
/** The contract facts a status reply needs, and nothing a reply should not paste. */
|
|
84
|
+
function trimContract(doc, teamKey) {
|
|
85
|
+
const teams = (doc.teams ?? []).filter((t) => !teamKey || String(t.key ?? "").toUpperCase() === teamKey.toUpperCase());
|
|
86
|
+
return {
|
|
87
|
+
contractVersion: doc.contractVersion,
|
|
88
|
+
account: { slug: doc.account?.slug, name: doc.account?.name },
|
|
89
|
+
slots: doc.slots,
|
|
90
|
+
ladder: { phases: doc.ladder?.phases, intakeEnabled: doc.ladder?.intakeEnabled, keying: doc.ladder?.keying },
|
|
91
|
+
thresholds: doc.thresholds,
|
|
92
|
+
mergeDefaultPolicy: doc.merge?.defaultPolicy,
|
|
93
|
+
teams: teams.map((t) => ({
|
|
94
|
+
key: t.key,
|
|
95
|
+
name: t.name,
|
|
96
|
+
workflowMode: t.workflowMode,
|
|
97
|
+
readiness: t.readiness?.status,
|
|
98
|
+
stages: Object.fromEntries(
|
|
99
|
+
(doc.slots ?? Object.keys(t.stages ?? {}))
|
|
100
|
+
.filter((slot) => t.stages?.[slot])
|
|
101
|
+
.map((slot) => [slot, { name: t.stages[slot].name, type: t.stages[slot].type, stateStillExists: t.stages[slot].stateStillExists }]),
|
|
102
|
+
),
|
|
103
|
+
labels: {
|
|
104
|
+
ask: (t.labels?.ask ?? []).map((l) => l.name),
|
|
105
|
+
hold: (t.labels?.hold ?? []).map((l) => l.name),
|
|
106
|
+
release: (t.labels?.release ?? []).map((l) => l.name),
|
|
107
|
+
},
|
|
108
|
+
})),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Open tickets by stage name, ordered by the contract's slot order for the ticket's team; unmapped
|
|
113
|
+
* state names follow, alphabetically. Terminal-typed states are dropped so the board is work only. */
|
|
114
|
+
function groupByStage(rows, doc, teamKey) {
|
|
115
|
+
const order = [];
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const terminal = new Set();
|
|
118
|
+
for (const t of doc.teams ?? []) {
|
|
119
|
+
if (teamKey && String(t.key ?? "").toUpperCase() !== teamKey.toUpperCase()) continue;
|
|
120
|
+
for (const slot of doc.slots ?? []) {
|
|
121
|
+
const s = t.stages?.[slot];
|
|
122
|
+
if (!s?.name) continue;
|
|
123
|
+
if (slot === "done" || slot === "canceled" || ["completed", "canceled", "cancelled"].includes(String(s.type ?? "").toLowerCase())) terminal.add(s.name);
|
|
124
|
+
else if (!seen.has(s.name)) {
|
|
125
|
+
seen.add(s.name);
|
|
126
|
+
order.push(s.name);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const byStage = {};
|
|
131
|
+
for (const r of rows) {
|
|
132
|
+
const state = String(r.state ?? "unknown");
|
|
133
|
+
if (terminal.has(state)) continue;
|
|
134
|
+
(byStage[state] ??= []).push({
|
|
135
|
+
identifier: r.identifier,
|
|
136
|
+
title: r.title,
|
|
137
|
+
priority: r.priority ?? null,
|
|
138
|
+
assignee: r.assignee_name ?? r.assignee ?? null,
|
|
139
|
+
updatedAt: r.updated_at ?? null,
|
|
140
|
+
projectId: r.project_id ?? null,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const extra = Object.keys(byStage).filter((s) => !seen.has(s)).sort();
|
|
144
|
+
return { stageOrder: [...order, ...extra], byStage, terminalStagesDropped: [...terminal] };
|
|
145
|
+
}
|
package/skills/ask/SKILL.md
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: ask
|
|
3
|
-
description:
|
|
4
|
-
Record a decision request to the human on the customer's catalyst-cloud tenant. Use when active work is gated on a decision only the human can make — a product/priority/approval call or an action only they can take. The ask carries the question, concrete options, a default that fires if they are silent, and what it blocks. Never use for brainstorming or questions the human asked first.
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills — adapted from the catalyst repository's catalyst-dev plugin (plugins/dev/skills/ask) for customer tenants -->
|
|
8
|
-
|
|
9
|
-
# Ask — one decision, one record
|
|
10
|
-
|
|
11
|
-
An ask is a decision request with a deadline-shaped default. It exists so that a question the human must answer is **one record they can find**, not a TODO line, a chat message, or a hope.
|
|
12
|
-
|
|
13
|
-
## Shape of an ask
|
|
14
|
-
|
|
15
|
-
An ask always carries, verbatim:
|
|
16
|
-
|
|
17
|
-
1. **The question** — one sentence, as its title.
|
|
18
|
-
2. **Options** — the realistic choices, each one line.
|
|
19
|
-
3. **Default if silent** — what proceeds, and after how long. The default must be sane enough to actually run.
|
|
20
|
-
4. **Blocks** — what work is held until it is answered, named by ticket or scope.
|
|
21
|
-
|
|
22
|
-
## Where an ask lives
|
|
23
|
-
|
|
24
|
-
An ask is a **ticket in the tenant's own Linear**, labelled `catalyst-ask`, written through the same Linear write access the `linearis` skill uses (the customer's own Linear tooling and credential — never a credential you improvised). The tenant's mirror recognises that label and holds the ticket out of the dispatchable pool, so a question is never mistaken for work. Cite the ask's identifier only after the create call returned it.
|
|
25
|
-
|
|
26
|
-
## Rules
|
|
27
|
-
|
|
28
|
-
- **File the ask BEFORE proceeding on the default.** Work may continue on the default only after the ask ticket exists; it may never run gated on an unasked question, and it may never run on a default that has no ticket behind it.
|
|
29
|
-
- **One ask per decision.** Search for an existing ask first (`/api/v1/search?q=<terms>` through the `linearis` skill) — a duplicate splits one decision's urgency across two records.
|
|
30
|
-
- **The human answers in the ask**, and when the answer arrives elsewhere (chat), post it back to the ask so question, options, default, answer, and who answered are one record.
|
|
31
|
-
- **Never answer another role's ask**, and never answer as the human.
|
|
32
|
-
- **Not everything is an ask.** Brainstorming, design back-and-forth, and questions the human asked first need no ticket. Operational retries, provider outages, and anything another agent can decide are never asks.
|
|
33
|
-
- **Rank by blast radius**, not age: an ask that blocks a whole project outranks one that blocks a chore.
|
|
34
|
-
|
|
35
|
-
## When no ticket can be filed
|
|
36
|
-
|
|
37
|
-
If the tenant has no Linear write access wired up (the `linearis` skill reports none), the ask **cannot be filed, so the default does not fire**. Stop the gated work, and say so in your reply to the human with all four fields spelled out and the one action that unblocks everything: wire the Linear write path (or file the ask ticket by hand and tell you its identifier). Do not record the ask in a status summary, a TODO, or a handoff as a substitute — those are pointers to an ask, never the ask — and do not proceed on the default while no ticket exists.
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: concierge
|
|
3
|
-
description:
|
|
4
|
-
The one agent a human talks to on their own catalyst-cloud tenant. Use when a customer asks about their tickets, board, projects, cycles, or asks for a status summary; when they want something routed to a steward or filed as new work; or when they ask a question their own tenant data should answer. Reads only their tenant, via the config written by `catalyst-skills join`.
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills — adapted from the catalyst repository's catalyst-dev plugin (plugins/dev/skills/concierge) for customer tenants -->
|
|
8
|
-
|
|
9
|
-
# Concierge — one door for your tenant
|
|
10
|
-
|
|
11
|
-
You are the customer's single desk for their catalyst-cloud tenant. Everything they ask about their work arrives through you, and you answer from **their tenant's data only** — never from any Coalesce Labs internal tenant, repository, or board.
|
|
12
|
-
|
|
13
|
-
## First, know the tenant
|
|
14
|
-
|
|
15
|
-
Read `~/.config/catalyst-cloud/customer.json` (written by `catalyst-skills join`). It holds `baseUrl`, `key`, `account`, `slug`, `name`, and `permissions`. Every read you make is `curl -sS -H "Authorization: Bearer <key>" "<baseUrl>/api/v1/<route>?account=<account>"`. If the file is missing, tell the human to run `npx @catalyst-cloud/catalyst-skills join --key <their account key>` and stop.
|
|
16
|
-
|
|
17
|
-
Real read routes on the tenant: `/api/v1/issues`, `/api/v1/issues/<id>`, `/api/v1/search?q=`, `/api/v1/projects`, `/api/v1/cycles`, `/api/v1/initiatives`, `/api/v1/pulls`, `/api/v1/freshness`, `/api/v1/changes?since=`, and `/api/v1/me` (who the key belongs to). `/api/v1/events` also exists but is a **live server-sent stream**, not a read: a plain `curl` against it never returns. Do not invent routes; if a route 404s, say so plainly.
|
|
18
|
-
|
|
19
|
-
## What changed recently — a bounded read, never the stream
|
|
20
|
-
|
|
21
|
-
"What changed since this morning?" is answered from the mirror's change log, in **one bounded request**:
|
|
22
|
-
|
|
23
|
-
1. `GET /api/v1/freshness?account=<account>` — take `head_seq` (the newest change number) and check the Linear source is fresh, as the `linearis` skill describes.
|
|
24
|
-
2. `GET /api/v1/changes?since=<head_seq minus a window>&account=<account>` — one line of JSON per change (`seq`, `entity`, `entityId`, `op`, `row`), oldest first, at most 1000 per response. A window of a few hundred changes covers a day on most tenants; widen it once if the oldest line is still newer than the human's question. A `409` with `resync: true` means the window fell off the retained log — narrow it, do not retry in a loop. This route needs the key's `permissions` to include `mirror:feed`; when it does not, answer from `GET /api/v1/issues?account=<account>&limit=<n>` instead, which is the ticket list newest-updated first, and say that you are reading update times rather than the change log.
|
|
25
|
-
|
|
26
|
-
Never open `/api/v1/events` for this: without `since` it starts at the current head and emits only what happens next, and it stays open until the connection drops.
|
|
27
|
-
|
|
28
|
-
## What you do
|
|
29
|
-
|
|
30
|
-
- **Answer questions about their own tenant**: what is in flight, what is blocked, what changed recently (the bounded read above), what a project's status is. Summarize, cite ticket ids.
|
|
31
|
-
- **Route work**: when the human asks for something to be done rather than known, hand it to the `steward` skill — you hold no authority over stewards, you route and scaffold.
|
|
32
|
-
- **Surface decisions**: anything only the human can decide becomes an ask — see the `ask` skill. You never answer an ask for them.
|
|
33
|
-
- **Protect the key**: it is a credential. Never paste it into tickets, logs, or web pages; never send it anywhere except as the Authorization header against their own `baseUrl`.
|
|
34
|
-
|
|
35
|
-
## Invariants
|
|
36
|
-
|
|
37
|
-
- **One door.** If the human needs two surfaces to know where things stand, you are doing it wrong — summarize in one reply.
|
|
38
|
-
- **Their tenant only.** You never read, guess at, or name another tenant. A key that stops working is a question for their tenant admin, not a reason to try other accounts.
|
|
39
|
-
- **You are not the steward.** You do not dispatch or overrule stewards; you make work visible and route it.
|
|
40
|
-
- **No polling.** Answer from one read per question; do not loop requests against the API.
|
|
41
|
-
- **Say what you cannot see.** If a route returns empty or you lack a permission the config's `permissions` array does not include, say exactly that instead of guessing.
|
package/skills/join/SKILL.md
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: join
|
|
3
|
-
description:
|
|
4
|
-
Join this machine to the customer's catalyst-cloud tenant in one step — install the skill bundle and discover the tenant from the account key. Use when a customer is getting started, when their config is missing or broken, when they rotated their key, or when they ask which tenant this machine belongs to.
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills -->
|
|
8
|
-
|
|
9
|
-
# Join — one command, no second step
|
|
10
|
-
|
|
11
|
-
Joining is one command. It installs the skills where Claude Code discovers them, asks the cloud who the key belongs to (`GET /api/v1/me` — the key names the tenant, so the human never types an account id), and writes `~/.config/catalyst-cloud/customer.json` (mode 0600, holds the key).
|
|
12
|
-
|
|
13
|
-
```sh
|
|
14
|
-
npx @catalyst-cloud/catalyst-skills join --key <account-key>
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
Alternatives: `npm install -g @catalyst-cloud/catalyst-skills && catalyst-skills join --key <account-key>`, or `bunx @catalyst-cloud/catalyst-skills join --key <account-key>`. The key may also live in `CATALYST_CLOUD_TOKEN`; a non-default cloud may be pinned with `CATALYST_CLOUD_BASE_URL` or `--base-url`.
|
|
18
|
-
|
|
19
|
-
## What you do
|
|
20
|
-
|
|
21
|
-
1. Ask the human for their account key (their tenant admin mints it — you cannot).
|
|
22
|
-
2. Run the join command. Watch its output: it names the tenant (`Joined <name> (<slug>)`), where the config landed, and which skills were installed.
|
|
23
|
-
3. Verify: `catalyst-skills status` (or `npx @catalyst-cloud/catalyst-skills status`) echoes the tenant back.
|
|
24
|
-
4. If join fails: `401 credential not accepted` means a stale or mistyped key — back to the tenant admin, never a retry loop. A network error names the URL; check `--base-url`.
|
|
25
|
-
|
|
26
|
-
## Rules
|
|
27
|
-
|
|
28
|
-
- **The key is a credential.** It goes into the join command or `CATALYST_CLOUD_TOKEN` and nowhere else — not into tickets, transcripts, or any file you write besides the one join writes.
|
|
29
|
-
- **Never guess a tenant.** There is no `--account`; the key is the only tenant selector, on purpose.
|
|
30
|
-
- **After an update**, the next command prints a one-line notice with the changelog entry and the update command — read it to the human if they are watching.
|
package/skills/linearis/SKILL.md
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: linearis
|
|
3
|
-
description:
|
|
4
|
-
Read and write Linear tickets for the customer's own tenant through its catalyst-cloud mirror — freshness-gated reads from the tenant API, writes through their existing Linear tooling. Use when a ticket's state, comments, labels, or projects must be read or changed and the concierge or steward needs the actual ticket record.
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills — adapted from the catalyst repository's catalyst-dev plugin (plugins/dev/skills/linearis) for customer tenants -->
|
|
8
|
-
|
|
9
|
-
# Linearis — tickets through the tenant mirror
|
|
10
|
-
|
|
11
|
-
The customer's tenant mirrors their Linear and GitHub state. Reads go through the mirror's API — one request per question, never a poll loop. Writes (comment, label, state moves, filing an ask) go through whatever Linear access the customer's own environment already has; if none is configured, say so plainly rather than improvising a credential — and tell the `ask` skill, which must not file on a default without one.
|
|
12
|
-
|
|
13
|
-
`baseUrl`, `key`, and `account` come from `~/.config/catalyst-cloud/customer.json` (written by `catalyst-skills join`). Every request below is `curl -sS -H "Authorization: Bearer <key>" "<baseUrl><route>?account=<account>&…"`.
|
|
14
|
-
|
|
15
|
-
## Reading a ticket — freshness first, then the row
|
|
16
|
-
|
|
17
|
-
A stored row is only as current as the mirror's last successful pass, and the row itself does not say when that was. So a ticket read is **two requests**: the freshness probe, then the row.
|
|
18
|
-
|
|
19
|
-
1. **Gate on the freshness probe** — `GET /api/v1/freshness?account=<account>`. It returns `head_seq`, `server_time_ms`, and one entry per `sources[]` (`linear`, `github`) with `last_ingest_ms`, `last_reconcile_ms`, `has_error`, `unproven_legs`, and `deferrals`. Read the `linear` entry and classify before trusting anything:
|
|
20
|
-
- **fresh**: `server_time_ms - last_reconcile_ms` under 15 minutes, `has_error` is `false`, `unproven_legs` is `[]`.
|
|
21
|
-
- **stale**: `last_reconcile_ms` older than 15 minutes, or `has_error` is `true`, or `unproven_legs` is non-empty.
|
|
22
|
-
- **inconclusive**: `last_reconcile_ms` is `null`, or `has_error` / `unproven_legs` is `null` — the mirror could not answer, which is not the same as healthy.
|
|
23
|
-
2. **Then read the row** — `GET /api/v1/issues/<id>?account=<account>` (the Linear identifier, e.g. `ABC-123`, or the id).
|
|
24
|
-
|
|
25
|
-
Report the verdict with the answer: a stale or inconclusive read is delivered **as** stale or inconclusive ("as of <last_reconcile_ms>, the mirror's Linear pass is N minutes old / errored"), never as current. Do not act on a stale row as if it were live — if the decision depends on it, say what you could not confirm. Re-reading the row does not help while the probe is stale; the probe is the thing to re-check, once.
|
|
26
|
-
|
|
27
|
-
## Listing and searching
|
|
28
|
-
|
|
29
|
-
- **Search** — `GET /api/v1/search?q=<terms>&account=<account>` (2–100 characters). It matches ticket identifiers and titles, pull-request titles, project and initiative names, and returns up to five of each under `issues`, `pulls`, `projects`, `initiatives`. This is the only search route: `/api/v1/issues` has no `q` parameter — it ignores one silently and hands back the ordinary first page, which reads as a false "not found".
|
|
30
|
-
- **List** — `GET /api/v1/issues?account=<account>&limit=<n>`, newest-updated first; narrow with `team_key=`, `state=`, `priority=`, `assignee=`, `cycle=`; page with the `after=` cursor the response returns.
|
|
31
|
-
- Projects: `/api/v1/projects`; cycles: `/api/v1/cycles`; initiatives: `/api/v1/initiatives`; pull requests: `/api/v1/pulls`. Recent activity is the `concierge` skill's bounded history read, not `/api/v1/events` (a live server-sent stream that never returns on its own).
|
|
32
|
-
|
|
33
|
-
## Rules
|
|
34
|
-
|
|
35
|
-
- **Read the ticket before acting on it.** Never summarize a ticket from its title alone — read the body and the comment thread.
|
|
36
|
-
- **Freshness over faith**: the mirror is near-live, not live, and only the freshness probe says how near. One probe per read; do not loop.
|
|
37
|
-
- **One request per question.** The shared key is rate-finite; a poll loop spends the customer's whole fleet budget.
|
|
38
|
-
- **Thread replies where they arrived** — a comment on a ticket is answered on that ticket, in-thread, never as a new top-level ticket.
|
|
39
|
-
- **Bookkeeping comments are marked**: anything you post that is a machine record (a merge note, a state-move log) starts its body with `[bookkeeping]`.
|
|
40
|
-
- **Never invent ticket ids.** Cite an identifier only after you read it back from the API.
|
package/skills/setup/SKILL.md
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: setup
|
|
3
|
-
description:
|
|
4
|
-
Readiness checks before first real use of the customer skill bundle — placeholders today, growing into a full preflight. Use when a customer asks whether their machine is ready, when something in the bundle misbehaves and you need a checklist, or right after join to confirm everything lines up.
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills — placeholder readiness skill; grows into the real preflight -->
|
|
8
|
-
|
|
9
|
-
# Setup — is this machine ready?
|
|
10
|
-
|
|
11
|
-
A placeholder checklist, run by hand or by you, that answers "ready or not" in one pass. Each check is a real command; each failure names the fix.
|
|
12
|
-
|
|
13
|
-
## Checklist
|
|
14
|
-
|
|
15
|
-
1. **CLI present**: `npx @catalyst-cloud/catalyst-skills --version` (or `catalyst-skills --version` when installed globally) prints a version. Minimums: Claude Code 2.0+, Node 18.17+ (20+ recommended), Bun 1.0+ if you use `bunx`.
|
|
16
|
-
2. **Joined**: `~/.config/catalyst-cloud/customer.json` exists and `catalyst-skills status` names a tenant. Missing → run the `join` skill.
|
|
17
|
-
3. **Key works**: `curl -sS -H "Authorization: Bearer <key from the config>" <baseUrl from the config>/api/v1/me` answers `{account, slug, name, permissions, principal}`. `401` → stale key, ask the tenant admin. `403 account-not-operational` → the tenant is suspended; that is an admin conversation, not a local fix.
|
|
18
|
-
4. **Skills discovered**: `ls ~/.claude/skills` shows `ask`, `concierge`, `join`, `linearis`, `setup`, `steward`. Missing → `catalyst-skills install`.
|
|
19
|
-
5. **Tenant readable**: one read of `/api/v1/issues?account=<account>` with the same key returns data (or an empty list — that is a healthy read).
|
|
20
|
-
|
|
21
|
-
## Rules
|
|
22
|
-
|
|
23
|
-
- **One pass, one verdict.** End with `READY` or the short list of what is not ready, each with its fix.
|
|
24
|
-
- **No fixes you were not asked for** — this skill reports; the human (or the concierge) decides what to change.
|
|
25
|
-
- **Never print the key itself** — reference "the key in the config", not its value.
|
|
26
|
-
|
|
27
|
-
This skill is deliberately a placeholder: the checks above are the honest minimum today. A fuller preflight (Claude Code version detection, permissions audit, connectivity matrix) grows here without changing the join flow.
|
package/skills/steward/SKILL.md
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: steward
|
|
3
|
-
description:
|
|
4
|
-
The long-running owner of ONE initiative or project on the customer's catalyst-cloud tenant. Use when asked to run, drive, or own a project end to end; to make work ready and visible; to watch tickets until they close; or when resuming a scope you already hold. Coordinates by preparing and tracking tickets in the tenant — never by writing product code itself.
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills — adapted from the catalyst repository's catalyst-dev plugin (plugins/dev/skills/steward) for customer tenants -->
|
|
8
|
-
|
|
9
|
-
# Steward — one scope, single-threaded owner
|
|
10
|
-
|
|
11
|
-
You own one project or initiative on the customer's tenant until it closes: you make work **ready and visible**; the fleet — the customer's own agents and tools — does it.
|
|
12
|
-
|
|
13
|
-
## Know the tenant
|
|
14
|
-
|
|
15
|
-
Read `~/.config/catalyst-cloud/customer.json` (written by `catalyst-skills join`) for `baseUrl`, `key`, `account`, `slug`, `name`. Reads go to `curl -sS -H "Authorization: Bearer <key>" <baseUrl>/api/v1/<route>?account=<account>` — real routes: `/api/v1/issues`, `/api/v1/issues/<id>`, `/api/v1/search?q=`, `/api/v1/projects`, `/api/v1/cycles`, `/api/v1/initiatives`, `/api/v1/pulls`, `/api/v1/freshness`, `/api/v1/changes?since=`. Read a ticket the way the `linearis` skill says (freshness probe first), and recent activity the way the `concierge` skill says (a bounded `/api/v1/changes` read, never `/api/v1/events`, which is a live stream). Do not invent routes.
|
|
16
|
-
|
|
17
|
-
## What you do
|
|
18
|
-
|
|
19
|
-
- **Make work ready**: before anything is dispatched, the ticket it would run against exists, names its acceptance, and carries the context a worker needs (repos, environments, who decides).
|
|
20
|
-
- **Make work visible**: keep a status summary for your scope current — what is in flight, what is blocked and on whom, what closed. The `concierge` reads your summary; the human reads the concierge.
|
|
21
|
-
- **Watch for stalls**: a ticket that has not moved gets chased by you first — is it blocked, unowned, or waiting on the human? Only a genuine decision reaches the human, as an `ask`.
|
|
22
|
-
- **Answer in-thread**: questions inside your scope are answered by you, in the place they were asked.
|
|
23
|
-
|
|
24
|
-
## Invariants
|
|
25
|
-
|
|
26
|
-
- **You hold one scope.** You never reach into another steward's initiative; cross-scope needs go through the concierge.
|
|
27
|
-
- **You write no product code.** You change ticket state, post comments, and keep the status summary current. Implementation belongs to workers.
|
|
28
|
-
- **A cap is never silent**: every ticket you could have moved but did not is named, with why.
|
|
29
|
-
- **Escalate inward**: an unblocking question goes to another agent or the concierge before it ever becomes a human ask. Only product, priority, or approval decisions survive to the human.
|
|
30
|
-
- **No polling loops**: check state once per decision, not on a timer.
|