@catalyst-cloud/cli 0.9.5 → 0.13.0
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 +22 -0
- package/README.md +4 -2
- package/dist/args.js +3 -1
- package/dist/cli.js +7 -2
- package/dist/env/declaration-rules.js +163 -0
- package/dist/env/group.js +51 -0
- package/dist/env/inventory.js +102 -0
- package/dist/env/render.js +39 -0
- package/dist/env/scan-env-example.js +62 -0
- package/dist/env/scan-source.js +43 -0
- package/dist/env/scan-workflow.js +123 -0
- package/dist/env/scan-wrangler.js +120 -0
- package/dist/env/types.js +1 -0
- package/dist/env/walk.js +87 -0
- package/dist/env.js +76 -0
- package/package.json +4 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/catalyst-setup/SKILL.md +1 -1
- package/skills/connect-me/SKILL.md +3 -3
- package/skills/how-catalyst-works/SKILL.md +4 -2
- package/skills/how-catalyst-works/references/settings-and-where-they-live.md +20 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +8 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-needs-me/references/reading-the-inbox.md +4 -0
- package/skills/what-this-repo-needs/SKILL.md +40 -0
- package/skills/what-this-repo-needs/agents/openai.yaml +6 -0
- package/skills/what-this-repo-needs/agents/portability.yaml +4 -0
- package/skills/what-this-repo-needs/references/what-each-group-means.md +68 -0
- package/skills/what-this-repo-needs/scripts/check.mjs +64 -0
- package/skills/what-this-repo-needs/scripts/inventory.mjs +61 -0
- package/skills/what-this-repo-needs/scripts/lib/cli.mjs +114 -0
- package/skills/what-this-repo-needs/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/SKILL.md +1 -1
- package/skills/whats-happening/references/reprioritising.md +1 -1
- package/skills/whats-happening/references/status-reply.md +2 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +41 -34
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// scan-workflow.ts — names a GitHub Actions workflow declares or references, read with the real YAML
|
|
2
|
+
// parser (`yaml`, D-2). A hand-written line scanner silently loses names written in flow style
|
|
3
|
+
// (`env: { A: 1, B: 2 }`); `parseDocument` does not, and it hands back the exact file:line for every
|
|
4
|
+
// key through `LineCounter`, plus the structural path (workflow env / a specific job) for free.
|
|
5
|
+
import { LineCounter, isMap, isScalar, isSeq, parseDocument } from "yaml";
|
|
6
|
+
const DEPLOY_JOB_ID_RE = /^(deploy|publish|release|cd)([-_].*)?$/i;
|
|
7
|
+
const DEPLOY_COMMANDS = ["wrangler deploy", "wrangler versions upload", "npm publish", "pnpm publish", "bun publish", "gh release create", "flyctl deploy", "vercel deploy"];
|
|
8
|
+
const SECRET_VAR_RE = /\$\{\{\s*(secrets|vars)\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
9
|
+
export function scanWorkflow(file, text) {
|
|
10
|
+
const lc = new LineCounter();
|
|
11
|
+
let doc;
|
|
12
|
+
try {
|
|
13
|
+
doc = parseDocument(text, { lineCounter: lc });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { sightings: [], jobs: [] };
|
|
17
|
+
}
|
|
18
|
+
const lineOf = (offset) => lc.linePos(offset).line;
|
|
19
|
+
const root = doc.contents;
|
|
20
|
+
const sightings = [];
|
|
21
|
+
const jobs = [];
|
|
22
|
+
const jobRanges = [];
|
|
23
|
+
if (!isMap(root))
|
|
24
|
+
return { sightings, jobs };
|
|
25
|
+
const topEnv = root.get("env", true);
|
|
26
|
+
if (isMap(topEnv)) {
|
|
27
|
+
for (const pair of topEnv.items) {
|
|
28
|
+
if (!isScalar(pair.key))
|
|
29
|
+
continue;
|
|
30
|
+
const offset = pair.key.range?.[0] ?? 0;
|
|
31
|
+
sightings.push({
|
|
32
|
+
name: String(pair.key.value),
|
|
33
|
+
finding: { file, line: lineOf(offset), consumer: "workflow env", consumerKind: "workflow-env" },
|
|
34
|
+
localSource: ".env file or your shell",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const jobsNode = root.get("jobs", true);
|
|
39
|
+
if (isMap(jobsNode)) {
|
|
40
|
+
for (const jobPair of jobsNode.items) {
|
|
41
|
+
if (!isScalar(jobPair.key) || !isMap(jobPair.value))
|
|
42
|
+
continue;
|
|
43
|
+
const jobId = String(jobPair.key.value);
|
|
44
|
+
const jobMap = jobPair.value;
|
|
45
|
+
const jobKeyOffset = jobPair.key.range?.[0] ?? 0;
|
|
46
|
+
const jobRange = jobMap.range ?? [jobKeyOffset, jobKeyOffset, jobKeyOffset];
|
|
47
|
+
let deploy = jobMap.get("environment", true) !== undefined;
|
|
48
|
+
let why = deploy ? "has an environment:" : "";
|
|
49
|
+
if (!deploy && DEPLOY_JOB_ID_RE.test(jobId)) {
|
|
50
|
+
deploy = true;
|
|
51
|
+
why = "job id matches a deploy pattern";
|
|
52
|
+
}
|
|
53
|
+
const steps = jobMap.get("steps", true);
|
|
54
|
+
if (isSeq(steps)) {
|
|
55
|
+
for (const step of steps.items) {
|
|
56
|
+
if (!isMap(step))
|
|
57
|
+
continue;
|
|
58
|
+
const run = step.get("run", true);
|
|
59
|
+
if (isScalar(run) && typeof run.value === "string") {
|
|
60
|
+
const cmd = DEPLOY_COMMANDS.find((c) => run.value.includes(c));
|
|
61
|
+
if (cmd) {
|
|
62
|
+
deploy = true;
|
|
63
|
+
why = `a step runs ${cmd}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const stepEnv = step.get("env", true);
|
|
67
|
+
if (isMap(stepEnv)) {
|
|
68
|
+
for (const pair of stepEnv.items) {
|
|
69
|
+
if (!isScalar(pair.key))
|
|
70
|
+
continue;
|
|
71
|
+
const offset = pair.key.range?.[0] ?? 0;
|
|
72
|
+
sightings.push({
|
|
73
|
+
name: String(pair.key.value),
|
|
74
|
+
finding: { file, line: lineOf(offset), consumer: `workflow job ${jobId}`, consumerKind: "workflow-job-env" },
|
|
75
|
+
jobId,
|
|
76
|
+
localSource: ".env file or your shell",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const jobEnv = jobMap.get("env", true);
|
|
83
|
+
if (isMap(jobEnv)) {
|
|
84
|
+
for (const pair of jobEnv.items) {
|
|
85
|
+
if (!isScalar(pair.key))
|
|
86
|
+
continue;
|
|
87
|
+
const offset = pair.key.range?.[0] ?? 0;
|
|
88
|
+
sightings.push({
|
|
89
|
+
name: String(pair.key.value),
|
|
90
|
+
finding: { file, line: lineOf(offset), consumer: `workflow job ${jobId}`, consumerKind: "workflow-job-env" },
|
|
91
|
+
jobId,
|
|
92
|
+
localSource: ".env file or your shell",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
jobs.push({ id: jobId, file, line: lineOf(jobKeyOffset), deploy, why: why || "no deploy signal" });
|
|
97
|
+
jobRanges.push({ id: jobId, start: jobRange[0], end: jobRange[2] ?? jobRange[1], deploy });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// ${{ secrets.X }} / ${{ vars.X }} references, over the RAW TEXT rather than the parsed tree: GitHub
|
|
101
|
+
// Actions interpolates these anywhere, including inside a `run: |` block's shell text, and a plain
|
|
102
|
+
// shell assignment inside that same block (e.g. `NOT_A_NAME=1`) must NOT be mined as a name — the
|
|
103
|
+
// raw-text regex only ever matches the `${{ … }}` expression syntax, never bare shell.
|
|
104
|
+
SECRET_VAR_RE.lastIndex = 0;
|
|
105
|
+
let m;
|
|
106
|
+
while ((m = SECRET_VAR_RE.exec(text))) {
|
|
107
|
+
const name = m[2];
|
|
108
|
+
const offset = m.index;
|
|
109
|
+
const job = jobRanges.find((j) => offset >= j.start && offset < j.end);
|
|
110
|
+
sightings.push({
|
|
111
|
+
name,
|
|
112
|
+
finding: { file, line: lineOf(offset), consumer: job ? `workflow job ${job.id}` : "workflow", consumerKind: `${m[1]}-ref` },
|
|
113
|
+
jobId: job?.id,
|
|
114
|
+
jobDeploy: job?.deploy,
|
|
115
|
+
localSource: "CI secret",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
for (const s of sightings) {
|
|
119
|
+
if (s.jobId !== undefined && s.jobDeploy === undefined)
|
|
120
|
+
s.jobDeploy = jobRanges.find((j) => j.id === s.jobId)?.deploy;
|
|
121
|
+
}
|
|
122
|
+
return { sightings, jobs };
|
|
123
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const BINDING_KEY_BY_TABLE = {
|
|
2
|
+
kv_namespaces: "binding",
|
|
3
|
+
d1_databases: "binding",
|
|
4
|
+
r2_buckets: "binding",
|
|
5
|
+
"durable_objects.bindings": "name",
|
|
6
|
+
"queues.producers": "binding",
|
|
7
|
+
"queues.consumers": "binding",
|
|
8
|
+
};
|
|
9
|
+
// ⛔ TABLE_RE AND QUOTED_RE ARE END-ANCHORED, SO AN INLINE `#` COMMENT MUST BE STRIPPED FIRST
|
|
10
|
+
// (validate attempt 29, CR-1 / CR-4). `[vars] # public settings` never matched TABLE_RE, so
|
|
11
|
+
// `currentTable` stayed null and the scanner returned NOTHING for the whole file — zero bindings,
|
|
12
|
+
// zero vars, zero notes: a silent, total failure in a tool whose entire value is completeness. And
|
|
13
|
+
// `binding = "SESSIONS" # the session store` never matched QUOTED_RE, so the `: value` fallback
|
|
14
|
+
// below made the raw remainder — quotes, comment and all — the binding NAME the customer is asked to
|
|
15
|
+
// keep, drop or move. `stripInlineComment` is quote-aware, so a `#` inside a value (`id = "ab#cd"`)
|
|
16
|
+
// is still value material and is left alone.
|
|
17
|
+
const QUOTED_RE = /^"(.*)"$|^'(.*)'$/;
|
|
18
|
+
const KEY_VALUE_RE = /^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.*)$/;
|
|
19
|
+
const TABLE_RE = /^\[\[?([^\]]+)\]\]?$/;
|
|
20
|
+
const ENV_TABLE_RE = /^env\.([^.]+)\.(.+)$/;
|
|
21
|
+
export function scanWrangler(file, text) {
|
|
22
|
+
const sightings = [];
|
|
23
|
+
const lines = text.split("\n");
|
|
24
|
+
let currentTable = null;
|
|
25
|
+
let currentEnvironment;
|
|
26
|
+
// Tracks an open triple-quoted string, so a line INSIDE one (e.g. `a = not_a_name`) is never
|
|
27
|
+
// mined for a key — the key that opened the string (e.g. `MULTI = """`) already was.
|
|
28
|
+
let insideMultiline = null;
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
const trimmed = lines[i].trim();
|
|
31
|
+
if (insideMultiline) {
|
|
32
|
+
if (trimmed.includes(insideMultiline))
|
|
33
|
+
insideMultiline = null;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
37
|
+
continue;
|
|
38
|
+
const uncommented = stripInlineComment(trimmed).trim();
|
|
39
|
+
if (uncommented === "")
|
|
40
|
+
continue;
|
|
41
|
+
const tableMatch = TABLE_RE.exec(uncommented);
|
|
42
|
+
if (tableMatch) {
|
|
43
|
+
let name = tableMatch[1].trim();
|
|
44
|
+
currentEnvironment = undefined;
|
|
45
|
+
const envMatch = ENV_TABLE_RE.exec(name);
|
|
46
|
+
if (envMatch) {
|
|
47
|
+
currentEnvironment = envMatch[1];
|
|
48
|
+
name = envMatch[2];
|
|
49
|
+
}
|
|
50
|
+
currentTable = name;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (currentTable === null)
|
|
54
|
+
continue;
|
|
55
|
+
const kv = KEY_VALUE_RE.exec(uncommented);
|
|
56
|
+
if (!kv)
|
|
57
|
+
continue;
|
|
58
|
+
const key = kv[1];
|
|
59
|
+
const value = kv[2].trim();
|
|
60
|
+
if (isOpeningMultiline(value, '"""'))
|
|
61
|
+
insideMultiline = '"""';
|
|
62
|
+
else if (isOpeningMultiline(value, "'''"))
|
|
63
|
+
insideMultiline = "'''";
|
|
64
|
+
if (currentTable === "vars" || currentTable.endsWith(".vars")) {
|
|
65
|
+
sightings.push({
|
|
66
|
+
name: key,
|
|
67
|
+
finding: { file, line: i + 1, consumer: "wrangler.toml vars", consumerKind: "vars" },
|
|
68
|
+
isWranglerVar: true,
|
|
69
|
+
wranglerEnvironment: currentEnvironment,
|
|
70
|
+
localSource: "value committed in wrangler.toml — nothing to declare",
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const bindingKey = BINDING_KEY_BY_TABLE[currentTable];
|
|
75
|
+
if (bindingKey && key === bindingKey) {
|
|
76
|
+
const qm = QUOTED_RE.exec(value);
|
|
77
|
+
const bindingName = qm ? (qm[1] ?? qm[2] ?? "") : value;
|
|
78
|
+
if (bindingName) {
|
|
79
|
+
sightings.push({
|
|
80
|
+
name: bindingName,
|
|
81
|
+
finding: { file, line: i + 1, consumer: `${currentTable} binding`, consumerKind: currentTable },
|
|
82
|
+
isBinding: true,
|
|
83
|
+
wranglerEnvironment: currentEnvironment,
|
|
84
|
+
localSource: "Cloudflare binding",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return sightings;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The line with any TOML inline comment removed. A `#` only starts a comment outside a string, so
|
|
93
|
+
* the scan tracks the quote it is inside. A line whose quotes do not balance — a `"""` opener, most
|
|
94
|
+
* importantly — is returned untouched rather than guessed at.
|
|
95
|
+
*/
|
|
96
|
+
function stripInlineComment(line) {
|
|
97
|
+
let quote = null;
|
|
98
|
+
for (let i = 0; i < line.length; i++) {
|
|
99
|
+
const ch = line[i];
|
|
100
|
+
if (quote !== null) {
|
|
101
|
+
if (ch === "\\")
|
|
102
|
+
i++;
|
|
103
|
+
else if (ch === quote)
|
|
104
|
+
quote = null;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (ch === '"' || ch === "'") {
|
|
108
|
+
quote = ch;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ch === "#")
|
|
112
|
+
return line.slice(0, i);
|
|
113
|
+
}
|
|
114
|
+
return line;
|
|
115
|
+
}
|
|
116
|
+
function isOpeningMultiline(value, marker) {
|
|
117
|
+
if (!value.startsWith(marker))
|
|
118
|
+
return false;
|
|
119
|
+
return !(value.length > marker.length && value.endsWith(marker));
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/env/walk.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// walk.ts — the one definition of "the files this repository is made of", shared by every scanner
|
|
2
|
+
// that needs to enumerate a tree. Skips the directories no scan should ever descend into.
|
|
3
|
+
//
|
|
4
|
+
// ⛔ A SYMLINK IS REPOSITORY CONTENT, AND REPOSITORY CONTENT IS UNTRUSTED (validate attempt 29,
|
|
5
|
+
// M-2 / CR-5). `statSync` FOLLOWS a link, so the previous walk had no way to tell a real directory
|
|
6
|
+
// from a link out of the tree, and no visited set to stop a cycle. Measured: a single `pkg/up -> ..`
|
|
7
|
+
// link reported one name 41 times with absurd file:line pairs, and a committed `elsewhere -> /tmp/x`
|
|
8
|
+
// walked a directory outside the repository entirely. So every entry is classified with a DIRENT
|
|
9
|
+
// (never a followed stat), and a link is followed only when its real path is inside the repository
|
|
10
|
+
// root and has not been walked before.
|
|
11
|
+
import { readdirSync, realpathSync, statSync } from "node:fs";
|
|
12
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
13
|
+
export const SKIP_DIRS = new Set(["node_modules", "dist", "coverage", ".git"]);
|
|
14
|
+
/** Every file under `root`, relative paths, POSIX-separated, skipping SKIP_DIRS. */
|
|
15
|
+
export function walkRepo(root) {
|
|
16
|
+
const out = [];
|
|
17
|
+
let realRoot;
|
|
18
|
+
try {
|
|
19
|
+
realRoot = realpathSync(root);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
// Real paths already descended into. Seeded with the root so a link back to it is a cycle.
|
|
25
|
+
const walked = new Set([realRoot]);
|
|
26
|
+
const stack = [root];
|
|
27
|
+
while (stack.length > 0) {
|
|
28
|
+
const dir = stack.pop();
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const name = entry.name;
|
|
38
|
+
const full = join(dir, name);
|
|
39
|
+
let isDirectory = entry.isDirectory();
|
|
40
|
+
if (entry.isSymbolicLink()) {
|
|
41
|
+
let real;
|
|
42
|
+
try {
|
|
43
|
+
real = realpathSync(full);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!contains(realRoot, real))
|
|
49
|
+
continue; // points out of the repository
|
|
50
|
+
try {
|
|
51
|
+
isDirectory = statSync(full).isDirectory();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (isDirectory) {
|
|
58
|
+
if (SKIP_DIRS.has(name))
|
|
59
|
+
continue;
|
|
60
|
+
// Every directory is keyed by its REAL path, link or not: `pkg/up -> pkg` is a cycle only
|
|
61
|
+
// because the link and the directory resolve to the same place, and checking the link alone
|
|
62
|
+
// would still walk the tree one extra time under its second name.
|
|
63
|
+
let real;
|
|
64
|
+
try {
|
|
65
|
+
real = realpathSync(full);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (walked.has(real))
|
|
71
|
+
continue; // a cycle, or a second route to the same tree
|
|
72
|
+
walked.add(real);
|
|
73
|
+
stack.push(full);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
out.push(relative(root, full).split("\\").join("/"));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out.sort();
|
|
81
|
+
}
|
|
82
|
+
/** True when `child` is `root` itself or lies beneath it. Both must already be real paths. */
|
|
83
|
+
export function contains(root, child) {
|
|
84
|
+
const a = resolve(root);
|
|
85
|
+
const b = resolve(child);
|
|
86
|
+
return b === a || b.startsWith(a.endsWith(sep) ? a : a + sep);
|
|
87
|
+
}
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// env.ts — `catalyst env inventory` / `env check`: a LOCAL, OFFLINE, repo-scoped reporter and
|
|
2
|
+
// validator. Unlike every other verb in this file's siblings, `cmdEnv` never calls `requireConfig` or
|
|
3
|
+
// `apiClient` — it needs neither a login nor a network call, which is the whole point of the feature:
|
|
4
|
+
// a person reviews what a repository declares without connecting anything first.
|
|
5
|
+
import { readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { positionals } from "./args.js";
|
|
8
|
+
import { validateDeclaration } from "./env/declaration-rules.js";
|
|
9
|
+
import { inventoryRepo } from "./env/inventory.js";
|
|
10
|
+
import { inventoryToJson, renderCheck, renderInventory } from "./env/render.js";
|
|
11
|
+
import { UsageError } from "./errors.js";
|
|
12
|
+
export async function cmdEnv(args, ctx, deps = {}) {
|
|
13
|
+
const [sub, ...rest] = positionals(args);
|
|
14
|
+
if (sub === undefined)
|
|
15
|
+
throw new UsageError("env needs a subcommand: inventory | check");
|
|
16
|
+
if (sub === "inventory") {
|
|
17
|
+
if (rest.length > 1)
|
|
18
|
+
throw new UsageError(`env inventory takes at most one path (got an extra "${rest[1]}")`);
|
|
19
|
+
const root = resolve(rest[0] ?? ".");
|
|
20
|
+
// C-3: walkRepo swallows a readdir failure and returns [], so without this an unreadable or
|
|
21
|
+
// mistyped path printed three empty groups and exited 0 — a typo that reads as "declares nothing".
|
|
22
|
+
let stat;
|
|
23
|
+
try {
|
|
24
|
+
stat = statSync(root);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
ctx.stderr(`${root} does not exist — env inventory takes a path to a repository`);
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
if (!stat.isDirectory()) {
|
|
31
|
+
ctx.stderr(`${root} is not a directory — env inventory takes a path to a repository`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
const inv = inventoryRepo(root, deps);
|
|
35
|
+
if (args.json)
|
|
36
|
+
ctx.stdout(JSON.stringify(inventoryToJson(inv)));
|
|
37
|
+
else
|
|
38
|
+
for (const line of renderInventory(inv))
|
|
39
|
+
ctx.stdout(line);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
if (sub === "check") {
|
|
43
|
+
const file = rest[0];
|
|
44
|
+
if (!file)
|
|
45
|
+
throw new UsageError("env check needs a file: catalyst env check <path to catalyst.env.json>");
|
|
46
|
+
if (rest.length > 1)
|
|
47
|
+
throw new UsageError(`env check takes exactly one file (got an extra "${rest[1]}")`);
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
text = readFileSync(file, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
ctx.stderr(`could not read ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
let doc;
|
|
57
|
+
try {
|
|
58
|
+
doc = JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// C-4/S-2: JSON.parse's own message embeds the first ten characters of the input, so
|
|
62
|
+
// forwarding it prints content from the file — on a verb documented as never printing a value.
|
|
63
|
+
ctx.stderr(`${file} is not valid JSON`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const errors = validateDeclaration(doc);
|
|
67
|
+
const state = errors.length === 0 ? "valid" : "invalid";
|
|
68
|
+
if (args.json)
|
|
69
|
+
ctx.stdout(JSON.stringify({ state, errors }));
|
|
70
|
+
else
|
|
71
|
+
for (const line of renderCheck({ state, errors }, file))
|
|
72
|
+
ctx.stdout(line);
|
|
73
|
+
return state === "valid" ? 0 : 1;
|
|
74
|
+
}
|
|
75
|
+
throw new UsageError(`unknown env subcommand "${sub}": inventory | check`);
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catalyst-cloud/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Customer skill bundle for Catalyst Cloud, named from the executive's seat — get set up, see what is happening, see what needs you, run a project, unstick what is stuck — plus the catalyst CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your tenant with one command. The skills directory is the roster; this line never counts it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -48,13 +48,13 @@
|
|
|
48
48
|
"paths:check": "node scripts/vendor-paths.mjs --check"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@catalyst-cloud/sdk": "^0.
|
|
51
|
+
"@catalyst-cloud/sdk": "^0.13.0",
|
|
52
|
+
"yaml": "^2.9.0"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@types/node": "^26.3.0",
|
|
55
56
|
"@vitest/coverage-v8": "^4.1.11",
|
|
56
57
|
"typescript": "^7.0.2",
|
|
57
|
-
"vitest": "^4.1.11"
|
|
58
|
-
"yaml": "^2.9.0"
|
|
58
|
+
"vitest": "^4.1.11"
|
|
59
59
|
}
|
|
60
60
|
}
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
|
|
5
5
|
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — written in this repository for customer tenants -->
|
|
8
8
|
|
|
9
9
|
# Catalyst's GitHub
|
|
10
10
|
|
|
@@ -5,7 +5,7 @@ description: >-
|
|
|
5
5
|
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
6
6
|
disable-model-invocation: true
|
|
7
7
|
---
|
|
8
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.
|
|
8
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — written in this repository for customer tenants -->
|
|
9
9
|
|
|
10
10
|
# Catalyst Linear
|
|
11
11
|
|
|
@@ -5,7 +5,7 @@ description: >-
|
|
|
5
5
|
disable-model-invocation: true
|
|
6
6
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
7
7
|
---
|
|
8
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.
|
|
8
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — written in this repository for customer tenants -->
|
|
9
9
|
|
|
10
10
|
# Onboard me
|
|
11
11
|
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Am I set up? Machine readiness (Node, the tenant connection, the cached contract, the CLI path, the skills, the SDK, the optional replica) plus tenant readiness from the contract's per-team checks, in one verdict: what passes, what is blocked, what is merely waiting, and who can click what. Use when someone asks "am I set up", "what is missing", "why does nothing happen", "is the replica running", or right after connecting a new machine. Reports; never repairs.
|
|
5
5
|
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — written in this repository for customer tenants -->
|
|
8
8
|
|
|
9
9
|
# Am I set up?
|
|
10
10
|
|
|
@@ -5,7 +5,7 @@ description: >-
|
|
|
5
5
|
disable-model-invocation: true
|
|
6
6
|
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
7
7
|
---
|
|
8
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.
|
|
8
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — written in this repository for customer tenants -->
|
|
9
9
|
|
|
10
10
|
# Connect me
|
|
11
11
|
|
|
@@ -34,7 +34,7 @@ CATALYST_CLOUD_TOKEN=<your-personal-key> catalyst-skills login
|
|
|
34
34
|
## What you do
|
|
35
35
|
|
|
36
36
|
1. Prefer keyless: run `catalyst-skills login` with no key and have the person approve the code in their browser (or from a phone). Nothing to mint, nothing to paste, nothing to keep out of the transcript. A **personal key** is the fallback for a script or unattended shell — they mint it at Settings → API keys (every active member can; no admin is needed) and hand it to the login command via `CATALYST_CLOUD_TOKEN`; it is shown once, so never put it in a file, a ticket, or this conversation beyond that one command. Either way, do not ask an admin for the tenant's **account key** (`ctc_acct_`, Settings → Account keys): that is a host credential — runners, daemons, the host replica — and a person connected with it has no name on anything their agent writes, and "what needs me" cannot mean them. If they connect with one anyway, login says so on stderr and still works.
|
|
37
|
-
2. Run the login command. Its output names the tenant (`Connected to <name> (<slug>)`), the person (`Connected as <label> (<role>)`), the config path, and the cached contract version. If the person line says their Linear identity is not matched yet, tell them: an admin matches it in Settings → Members, and until then "what needs me" shows everyone's asks.
|
|
37
|
+
2. Run the login command. Its output names the tenant (`Connected to <name> (<slug>)`), the person (`Connected as <label> (<role>)`), the config path, and the cached contract version. If the person line says their Linear identity is not matched yet, tell them: an admin matches it in Settings → Members, and until then "what needs me" shows everyone's asks — connecting Linear personally does not match the identity.
|
|
38
38
|
3. Verify with `node scripts/verify-connection.mjs`: one line each for the tenant, the contract version, and the replica; exit 1 when the machine is not connected.
|
|
39
39
|
4. Run `catalyst-skills ready` and read the verdict to them.
|
|
40
40
|
5. Do not offer the replica. It is optional and off by default for large projects while the snapshot path is made safe, and every read works through the API without it. Start it only if the person asks for local SQL, and then load `references/keeping-the-replica-running.md`.
|
|
@@ -54,7 +54,7 @@ Every skill session opens with these, in this order, before doing anything else:
|
|
|
54
54
|
| when | read |
|
|
55
55
|
| -- | -- |
|
|
56
56
|
| the person wants the replica writer to survive a reboot, or asks what it stores and whether anything needs cleaning | `references/keeping-the-replica-running.md` |
|
|
57
|
-
| the full readiness vector and
|
|
57
|
+
| the full readiness vector — the check, its severity, and who can click what | the `catalyst-setup` skill |
|
|
58
58
|
|
|
59
59
|
## Rules
|
|
60
60
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: how-catalyst-works
|
|
3
3
|
description: >-
|
|
4
|
-
How Catalyst Cloud runs a ticket on the customer's own tenant, as facts an agent loads on demand: the eight-phase ladder and what each phase produces, the eleven board slots and this team's live stage map, what happens when a phase fails (retry, backoff, Remediate, park), how the queue is ordered and routed and every reason a ticket is excluded,
|
|
4
|
+
How Catalyst Cloud runs a ticket on the customer's own tenant, as facts an agent loads on demand: the eight-phase ladder and what each phase produces, the eleven board slots and this team's live stage map, what happens when a phase fails (retry, backoff, Remediate, park), how the queue is ordered and routed and every reason a ticket is excluded, the coding-account model, and which settings screen governs which rule. Use when a person asks "how does this work?", "why did it do that?", "why is this stuck?", "what runs next?", "how does it prioritise?" or "what does this setting do, where is it set?". Read-only; its scripts explain one ticket's eligibility in plain English, show what is running and queued, and print the tenant's stage map and thresholds straight from the contract.
|
|
5
5
|
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — written in this repository for customer tenants -->
|
|
8
8
|
|
|
9
9
|
# How Catalyst works
|
|
10
10
|
|
|
@@ -32,10 +32,12 @@ Exit codes: 0 answered, 1 not found or a usage error, 2 this machine is not conn
|
|
|
32
32
|
| a phase FAILED, a card went to Remediate, a ticket is parked or on hold | `references/when-a-phase-fails.md` |
|
|
33
33
|
| "what runs next, why not this one, what does this exclusion reason mean?", "why does nothing start while nothing runs?" (the WIP limit) | `references/what-runs-next.md` |
|
|
34
34
|
| "why is nothing running", walls, quarantine, which provider ran a phase | `references/coding-accounts.md` |
|
|
35
|
+
| "what does this setting do, where is it set, who can change it?" | `references/settings-and-where-they-live.md` |
|
|
35
36
|
|
|
36
37
|
## Rules
|
|
37
38
|
|
|
38
39
|
- **Print, never recall.** A stage name, label, threshold or route in your answer must have come from a script's output in this session.
|
|
40
|
+
- **A setting has a page.** Name the screen, its route and the rule from `references/settings-and-where-they-live.md`; never answer a settings question from memory, and never invent a route.
|
|
39
41
|
- **A reason is a layer.** Translate an exclusion reason through `references/what-runs-next.md`; name what releases it and who can do that (a clock, a comment, a push, an operator).
|
|
40
42
|
- **Unknown is not absent.** An `unknown` verdict from the explainer means the cloud could not look; report it as inconclusive.
|
|
41
43
|
- **System causes are one alert.** A provider outage, the runner image breaker or a paused repository holds many tickets for one reason; never escalate it ticket by ticket.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Settings and where they live
|
|
2
|
+
|
|
3
|
+
This reference restates rules that do not vary per tenant: what a setting does, the screen it lives on, and who can change it. Every live value — whether a connection is up, which repositories are registered, the current merge policy — comes from the contract or a script; every route below is prefixed with `<their cloud>` because the host is the tenant's own, never a literal you invent.
|
|
4
|
+
|
|
5
|
+
| what | where it is set | the rule | who can set it |
|
|
6
|
+
| -- | -- | -- | -- |
|
|
7
|
+
| **Connecting Linear** | `<their cloud>/settings/connections` | Needs an admin or owner browser session; the tenant comes from that session, never from a URL parameter. | owner or admin |
|
|
8
|
+
| **One workspace, one account** | `<their cloud>/settings/connections` | A Linear workspace binds to exactly one account and an account to exactly one workspace; a second tenant for the same workspace is refused and stores nothing. That is a decision to raise, not a retry. | owner or admin |
|
|
9
|
+
| **Installing the GitHub App** | `<their cloud>/settings/connections`, but the admin's own personal GitHub grant comes first at `<their cloud>/settings/profile` | A refusal naming a missing GitHub identity, or an installation that is not yours, means "grant your personal GitHub first", never "the App is broken". | owner or admin |
|
|
10
|
+
| **Uninstalling is safe either way** | `<their cloud>/settings/connections` | Removing the App on GitHub self-heals the registry; unlinking in Catalyst uninstalls upstream best-effort. Neither needs a support ticket. | owner or admin |
|
|
11
|
+
| **One team, one repository** | `<their cloud>/settings/repositories` | One Linear team maps to at most one repository per account, and one GitHub repository to at most one, case-insensitively. | owner or admin |
|
|
12
|
+
| **Neither half can be re-pointed** | `<their cloud>/settings/repositories` | A repository is exactly one team plus one GitHub repo; the only editable field is its name. A monorepo across two teams, or one team across two repos, cannot be modelled today — do not promise it. | owner or admin |
|
|
13
|
+
| **An archived repository still holds both keys** | `<their cloud>/settings/repositories` | Re-registering the same team-plus-repo pair is refused until the archived entry is restored. | owner or admin |
|
|
14
|
+
| **Merge policy** | `<their cloud>/settings/repositories/$repoId/merging` | The effective policy and where it came from are served on the contract's `merge` block; this is the page that changes it (`catalyst-github`'s mergeability read never sets it). | owner or admin |
|
|
15
|
+
| **Reviewers** | `<their cloud>/settings/repositories/$repoId/code-reviews` | "Post review requests as" names one connected member; a member cannot volunteer or refuse. A strict merge policy with no reviewer configured never earns the merge label — configure a reviewer, or change the policy on the merging page. | owner or admin |
|
|
16
|
+
| **The merge queue is assumed, and this bundle cannot see it** | (no settings page; the repository's own merge automation — a Mergify configuration, GitHub's native merge queue, or whatever else that repository runs) | The merge phase never merges: its one write is the ready label. Without a queue consuming that label the label is still the cloud's attestation, and the merge itself is whatever the repository's own setup does with it. Nothing here detects or repairs that, and nothing here knows which queue a tenant runs; say that by name rather than guessing, and never name a vendor the tenant has not named first. | whoever administers the GitHub repository |
|
|
17
|
+
| **The runner cap** | `<their cloud>/settings/repositories` | Default 20 running phases per repository; a paused repository resolves to zero without losing the stored value. The tenant page is display-only — lowering it is an operator action. See `references/what-runs-next.md`'s concurrency section. | operator (the page only displays it) |
|
|
18
|
+
| **Secrets are write-only; environment variables are readable** | `<their cloud>/settings/secrets` and `<their cloud>/settings/environment`, plus the per-repository sections under `<their cloud>/settings/repositories` | A name is a secret **or** an environment variable, never both; a repository-scoped value wins over a tenant-scoped one; an environment variable may reference a vault secret by name, expanded only when the container is built. | owner or admin |
|
|
19
|
+
| **"My secrets are not in the container"** | `<their cloud>/settings/repositories` (the team the repository is registered under) | When a repository was registered without naming its team, nothing reaches a runner, and the failure is silent — the symptom is a phase whose build cannot find its environment. The fix is an admin or operator action, not a retry; raise it as an ask. Name the symptom and the page, never the registry table behind it. | owner or admin |
|
|
20
|
+
| **Routing has no write route and no UI** | none — there is no settings page for this | Stage defaults are global and the per-tenant precedence table is empty, so an empty precedence always resolves to the default provider. A Codex-first pipeline is not something to promise today. See `references/coding-accounts.md` and `references/what-runs-next.md`'s routing decision. | nobody — not configurable yet |
|
|
@@ -73,7 +73,14 @@ No survivor is `no_eligible_account_slot` when any candidate was skipped on capa
|
|
|
73
73
|
| `routing_unavailable` | claimed then refused at kickoff: no route, no eligible slot, or the provider is unavailable; the detail names which |
|
|
74
74
|
| `repo_paused` | an operator paused the repository |
|
|
75
75
|
| `wip_limit` | the project is at its WIP limit (see above): its tickets in progress are waiting, and this new start waits until one of them finishes |
|
|
76
|
-
| `remediate_parked` | the remediate phase is parked, so the failing phase has nowhere to be repaired |
|
|
76
|
+
| `remediate_parked` | the remediate phase is parked, so the failing phase has nowhere to be repaired; `catalyst-skills release <ticket>` releases the park once its cause is fixed |
|
|
77
|
+
| `phase_parked` | the offered phase is parked after repeated failures or a spent repair-round cap; the person releases it once its cause is fixed |
|
|
78
|
+
| `later_phase_lease_held` | an earlier phase is offered while a live container still holds a later phase of this ticket |
|
|
79
|
+
| `human_owned_pr` | a person's own pull request holds this ticket; that person closes or merges the PR themselves, and no release command clears it |
|
|
80
|
+
| `review_not_converging` | review and repair kept finding new problems without converging |
|
|
81
|
+
| `round_threshold` | the ticket spent its lifetime repair budget |
|
|
82
|
+
| `claim_storm` | claimed too many times in the last hour; it waits the hour out, and there is nothing to release |
|
|
83
|
+
| `repo_at_capacity` | the repository's runner seats are all in use; it starts when one frees |
|
|
77
84
|
|
|
78
85
|
## The unknowns (the evaluator fails closed)
|
|
79
86
|
|
|
@@ -5,7 +5,7 @@ description: >-
|
|
|
5
5
|
disable-model-invocation: true
|
|
6
6
|
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
7
7
|
---
|
|
8
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.
|
|
8
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — written in this repository for customer tenants -->
|
|
9
9
|
|
|
10
10
|
# Run this project
|
|
11
11
|
|
package/skills/unstick/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: >-
|
|
|
5
5
|
disable-model-invocation: true
|
|
6
6
|
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
7
7
|
---
|
|
8
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.
|
|
8
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — written in this repository for customer tenants -->
|
|
9
9
|
|
|
10
10
|
# Unstick
|
|
11
11
|
|
|
@@ -5,7 +5,7 @@ description: >-
|
|
|
5
5
|
disable-model-invocation: true
|
|
6
6
|
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
7
7
|
---
|
|
8
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.
|
|
8
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.0 — written in this repository for customer tenants -->
|
|
9
9
|
|
|
10
10
|
# What needs me
|
|
11
11
|
|
|
@@ -19,6 +19,10 @@ So an ask that holds nothing is real, but the person may never see it in their o
|
|
|
19
19
|
|
|
20
20
|
`catalyst-skills ask list` reads every page of the tenant's issues, not a windowed sample, so an empty list means nothing is assigned to you rather than that the read stopped early. It reads the open tickets carrying the team's ask label (by the label ids and prefix the contract serves), follows each one's blocking relations to the tickets it holds, drops held tickets that are already terminal, and scores the rest by priority weight: urgent 4, high 3, medium 2, low 1, none 1. It filters to **the connected person** by default: `login` recorded who they are and their Linear user id, and the list keeps only asks assigned to that id. `--anyone` lists the whole tenant. The JSON answer carries a `scope` — `mine` (with the label and Linear id), `anyone`, `unmatched` (the person's Linear identity is not matched yet: an admin does that in Settings → Members, and until then the whole list is shown with a stderr line saying so), or `no-person` (the machine is connected with the tenant's account key, which names nobody: the whole list, with a line saying to log in with a personal key). Read the scope before presenting the list, and say which you got: "what needs me" is the `mine` list, "what needs anyone" is `--anyone`, and an empty `mine` list names the wider count so it never reads as "nothing needs anyone".
|
|
21
21
|
|
|
22
|
+
## The identity is not the same as the personal Linear grant
|
|
23
|
+
|
|
24
|
+
Two different stored facts can disagree, and when asks are not reaching someone this is the first thing to check, before the grant. The **identity** is who you are in Linear: resolved automatically by an email match, or picked by an admin at Settings → Members when it is not. The **personal Linear grant** is proven by connecting Linear yourself — a person action, not an admin one. Connecting Linear personally does **not** set the identity; the two are separate, and the identity is checked first, then the grant. An identity that resolved automatically cannot be re-pointed by the member themselves — that needs an operator; a manual pick by an admin is refused if another member already claims that Linear user. Everything that notifies or assigns a human needs a resolved identity: with no identity there is no ask and no push, and nothing is assigned to that person — even for someone who has connected Linear themselves. Do not report that as an empty queue. `what needs me` does not go quiet in that state: the scope comes back `unmatched`, the whole tenant's open asks are listed, and a line says the identity is not matched yet (*What the script reads*, above). The honest answer is "nothing is assigned to you yet, so this is everyone's list, not yours — an admin matches your identity at Settings → Members".
|
|
25
|
+
|
|
22
26
|
## Presenting the queue
|
|
23
27
|
|
|
24
28
|
- Ranked by score, highest first. Never by age, never by identifier. An ask that holds a project outranks one that holds a chore, whatever their dates.
|