cue-ai 0.5.0 → 0.7.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/README.md +757 -110
- package/package.json +5 -5
- package/profiles/README.md +12 -12
- package/profiles/SCHEMA.md +31 -3
- package/profiles/_cache/README.md +1 -1
- package/profiles/_types.ts +26 -1
- package/profiles/backend/profile.yaml +1 -0
- package/profiles/career/profile.yaml +13 -0
- package/profiles/core/profile.yaml +76 -9
- package/profiles/creative-media/README.md +1 -1
- package/profiles/cybersecurity/profile.yaml +779 -756
- package/profiles/ecc/profile.yaml +39 -0
- package/profiles/event-design/profile.yaml +10 -0
- package/profiles/fleet-control/README.md +1 -1
- package/profiles/frontend/profile.yaml +14 -0
- package/profiles/full/README.md +1 -1
- package/profiles/go-api/profile.yaml +1 -0
- package/profiles/marketing/profile.yaml +12 -1
- package/profiles/predict-everything/profile.yaml +9 -0
- package/profiles/rust/profile.yaml +22 -3
- package/profiles/rust-cli/profile.yaml +14 -0
- package/profiles/rust-core/profile.yaml +35 -0
- package/profiles/rust-embedded/profile.yaml +11 -0
- package/profiles/rust-ffi/profile.yaml +13 -0
- package/profiles/rust-game/profile.yaml +11 -0
- package/profiles/rust-wasm/profile.yaml +11 -0
- package/profiles/rust-web/profile.yaml +17 -0
- package/profiles/schema.json +44 -4
- package/profiles/trendradar/profile.yaml +11 -0
- package/resources/mcps/README.md +39 -164
- package/resources/mcps/configs/claude.sanitized.json +55 -0
- package/resources/mcps/configs/claude_runtime.sanitized.json +47 -0
- package/resources/skills/README.md +70 -113
- package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +43 -0
- package/resources/skills/skills/meta/acpx/SKILL.md +78 -0
- package/resources/skills/skills/meta/cue-usage/SKILL.md +24 -0
- package/resources/skills/skills/meta/profile-fit-monitor/SKILL.md +24 -0
- package/resources/skills/skills/predict-everything/mirofish/SKILL.md +75 -0
- package/resources/skills/skills/research/trendradar/SKILL.md +88 -0
- package/resources/skills/skills/rust/async-tokio/SKILL.md +27 -0
- package/resources/skills/skills/rust/axum-api/SKILL.md +38 -0
- package/resources/skills/skills/rust/bacon-watch/SKILL.md +24 -0
- package/resources/skills/skills/rust/bevy/SKILL.md +43 -0
- package/resources/skills/skills/rust/bindgen/SKILL.md +39 -0
- package/resources/skills/skills/rust/cargo-audit/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-basics/SKILL.md +28 -0
- package/resources/skills/skills/rust/cargo-chef/SKILL.md +43 -0
- package/resources/skills/skills/rust/cargo-edit/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-expand/SKILL.md +24 -0
- package/resources/skills/skills/rust/cargo-flamegraph/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-fuzz/SKILL.md +34 -0
- package/resources/skills/skills/rust/cargo-hack/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-msrv/SKILL.md +30 -0
- package/resources/skills/skills/rust/cargo-mutants/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-nextest/SKILL.md +24 -0
- package/resources/skills/skills/rust/cargo-readme/SKILL.md +36 -0
- package/resources/skills/skills/rust/cbindgen/SKILL.md +41 -0
- package/resources/skills/skills/rust/chisel-tool/SKILL.md +32 -0
- package/resources/skills/skills/rust/clap-cli/SKILL.md +44 -0
- package/resources/skills/skills/rust/clippy-and-fmt/SKILL.md +25 -0
- package/resources/skills/skills/rust/cross-compile/SKILL.md +26 -0
- package/resources/skills/skills/rust/embedded/SKILL.md +33 -0
- package/resources/skills/skills/rust/error-handling/SKILL.md +32 -0
- package/resources/skills/skills/rust/just-runner/SKILL.md +26 -0
- package/resources/skills/skills/rust/mdbook/SKILL.md +25 -0
- package/resources/skills/skills/rust/napi-rs/SKILL.md +32 -0
- package/resources/skills/skills/rust/no-std/SKILL.md +42 -0
- package/resources/skills/skills/rust/property-testing/SKILL.md +35 -0
- package/resources/skills/skills/rust/pyo3/SKILL.md +40 -0
- package/resources/skills/skills/rust/ratatui-tui/SKILL.md +36 -0
- package/resources/skills/skills/rust/release-plz/SKILL.md +27 -0
- package/resources/skills/skills/rust/reqwest/SKILL.md +37 -0
- package/resources/skills/skills/rust/sccache/SKILL.md +28 -0
- package/resources/skills/skills/rust/serde/SKILL.md +30 -0
- package/resources/skills/skills/rust/snapshot-testing/SKILL.md +30 -0
- package/resources/skills/skills/rust/sqlx-cli/SKILL.md +33 -0
- package/resources/skills/skills/rust/tracing/SKILL.md +36 -0
- package/resources/skills/skills/rust/typos-spellcheck/SKILL.md +31 -0
- package/resources/skills/skills/rust/uniffi/SKILL.md +38 -0
- package/resources/skills/skills/rust/wasm-rust/SKILL.md +27 -0
- package/resources/skills/skills/security/agentshield/SKILL.md +119 -0
- package/src/commands/_index.ts +47 -3
- package/src/commands/cli.test.ts +192 -0
- package/src/commands/cli.ts +303 -0
- package/src/commands/current.ts +1 -1
- package/src/commands/debug.test.ts +62 -0
- package/src/commands/debug.ts +212 -0
- package/src/commands/discover.scoring.test.ts +216 -0
- package/src/commands/discover.test.ts +145 -0
- package/src/commands/discover.ts +2618 -0
- package/src/commands/eval-behavior.test.ts +56 -0
- package/src/commands/eval-behavior.ts +189 -0
- package/src/commands/eval.test.ts +102 -0
- package/src/commands/eval.ts +348 -0
- package/src/commands/evolve.ts +291 -0
- package/src/commands/failures.test.ts +78 -0
- package/src/commands/failures.ts +393 -0
- package/src/commands/feedback.ts +219 -0
- package/src/commands/init.ts +26 -0
- package/src/commands/launch.e2e.test.ts +9 -1
- package/src/commands/launch.ts +174 -11
- package/src/commands/lint-skill.ts +157 -0
- package/src/commands/marketplace.ts +763 -2
- package/src/commands/new.ts +1 -1
- package/src/commands/optimizer.ts +92 -28
- package/src/commands/profile-draft-skill.test.ts +96 -0
- package/src/commands/profile-draft-skill.ts +287 -0
- package/src/commands/profile-evolve.test.ts +126 -0
- package/src/commands/profile-evolve.ts +0 -0
- package/src/commands/profile-suggest.ts +223 -0
- package/src/commands/profile.ts +41 -0
- package/src/commands/quick.ts +2 -17
- package/src/commands/scan.ts +2 -2
- package/src/commands/score.ts +1 -1
- package/src/commands/share.ts +1 -1
- package/src/commands/sources.ts +2 -2
- package/src/commands/submit-profile.ts +262 -0
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/use.ts +35 -5
- package/src/commands/validate.ts +1 -1
- package/src/index.ts +66 -0
- package/src/lib/analytics.ts +48 -2
- package/src/lib/claude-binary.ts +39 -0
- package/src/lib/cli-extractor.ts +77 -0
- package/src/lib/cluster-skills.test.ts +268 -0
- package/src/lib/cluster-skills.ts +290 -0
- package/src/lib/credentials-sync.test.ts +208 -0
- package/src/lib/credentials-sync.ts +205 -0
- package/src/lib/mcp-materializer.test.ts +1 -1
- package/src/lib/persona-playbooks.test.ts +111 -0
- package/src/lib/pr-poster.test.ts +243 -0
- package/src/lib/pr-poster.ts +285 -0
- package/src/lib/pr-throttle.test.ts +148 -0
- package/src/lib/pr-throttle.ts +209 -0
- package/src/lib/profile-generator.test.ts +1 -1
- package/src/lib/profile-generator.ts +2 -2
- package/src/lib/profile-linter.test.ts +6 -3
- package/src/lib/profile-linter.ts +71 -8
- package/src/lib/profile-loader.test.ts +1 -1
- package/src/lib/profile-loader.ts +16 -0
- package/src/lib/resolver-local.test.ts +1 -1
- package/src/lib/resolver-npx.test.ts +76 -1
- package/src/lib/resolver-npx.ts +35 -3
- package/src/lib/resolver-plugins.test.ts +1 -1
- package/src/lib/runtime-materializer.test.ts +191 -7
- package/src/lib/runtime-materializer.ts +310 -42
- package/src/lib/scan-plugins.test.ts +1 -1
- package/src/lib/skill-linter.test.ts +174 -0
- package/src/lib/skill-linter.ts +507 -0
- package/src/lib/skill-subset.test.ts +95 -0
- package/src/lib/skill-subset.ts +166 -0
- package/src/lib/star-prompt.ts +1 -1
- package/src/lib/uvx-installer.test.ts +229 -0
- package/src/lib/uvx-installer.ts +278 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `cue failures`. Uses a synthetic session-log + a fake transcript
|
|
3
|
+
* directory so no real ~/.claude state is touched.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
7
|
+
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir, homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { run as failuresRun } from "./failures";
|
|
12
|
+
|
|
13
|
+
let tmp: string;
|
|
14
|
+
let originalXdg: string | undefined;
|
|
15
|
+
let originalHome: string | undefined;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmp = `${tmpdir()}/cue-failures-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
19
|
+
mkdirSync(tmp, { recursive: true });
|
|
20
|
+
originalXdg = process.env.XDG_CONFIG_HOME;
|
|
21
|
+
originalHome = process.env.HOME;
|
|
22
|
+
process.env.XDG_CONFIG_HOME = join(tmp, "config");
|
|
23
|
+
// Note: HOME is read at module import via os.homedir(); failures.ts captures
|
|
24
|
+
// PROJECTS_DIR at module load, so we can't easily redirect it after the fact.
|
|
25
|
+
// Test focuses on session-log parsing which IS env-lazy.
|
|
26
|
+
|
|
27
|
+
// Seed a synthetic session log with two entries
|
|
28
|
+
const logDir = join(tmp, "config", "cue");
|
|
29
|
+
mkdirSync(logDir, { recursive: true });
|
|
30
|
+
const log = join(logDir, "session-log.jsonl");
|
|
31
|
+
const now = new Date().toISOString();
|
|
32
|
+
writeFileSync(log,
|
|
33
|
+
JSON.stringify({ ts: now, cwd: "/x", profile: "test-profile-a", session_id: "s1" }) + "\n" +
|
|
34
|
+
JSON.stringify({ ts: now, cwd: "/y", profile: "test-profile-b", session_id: "s2" }) + "\n"
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
40
|
+
else process.env.XDG_CONFIG_HOME = originalXdg;
|
|
41
|
+
if (originalHome !== undefined) process.env.HOME = originalHome;
|
|
42
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
async function capture<T>(fn: () => Promise<T>): Promise<{ stdout: string; value: T }> {
|
|
46
|
+
const orig = process.stdout.write.bind(process.stdout);
|
|
47
|
+
let buf = "";
|
|
48
|
+
(process.stdout as any).write = (c: string | Uint8Array) => { buf += String(c); return true; };
|
|
49
|
+
try { const value = await fn(); return { stdout: buf, value }; }
|
|
50
|
+
finally { (process.stdout as any).write = orig; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("cue failures", () => {
|
|
54
|
+
test("--json output reports session count + zero failures when no markers found", async () => {
|
|
55
|
+
// Note: failures.ts reads SESSION_LOG via env-lazy join, but caches the
|
|
56
|
+
// module path at import. The XDG_CONFIG_HOME redirect set in beforeEach
|
|
57
|
+
// takes effect because SESSION_LOG is computed inside readSessionLog().
|
|
58
|
+
// However, transcripts come from ~/.claude/projects which we can't redirect
|
|
59
|
+
// mid-test, so transcripts may be 0 in CI. The session-log path IS exercised.
|
|
60
|
+
const { stdout, value } = await capture(() => failuresRun(["--days", "30", "--json"]));
|
|
61
|
+
expect(value).toBe(0);
|
|
62
|
+
const out = JSON.parse(stdout) as { sinceDays: number; sessions: number; byProfile: Record<string, any> };
|
|
63
|
+
expect(out.sinceDays).toBe(30);
|
|
64
|
+
expect(out.sessions).toBeGreaterThanOrEqual(0); // could be 0 if XDG_CONFIG_HOME doesn't propagate
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("text output renders the header and helpful guidance when empty", async () => {
|
|
68
|
+
const { stdout } = await capture(() => failuresRun(["--days", "1"]));
|
|
69
|
+
expect(stdout).toContain("Failure pattern review");
|
|
70
|
+
expect(stdout).toContain("last 1 days");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("--days defaults to 7", async () => {
|
|
74
|
+
const { stdout } = await capture(() => failuresRun(["--json"]));
|
|
75
|
+
const out = JSON.parse(stdout);
|
|
76
|
+
expect(out.sinceDays).toBe(7);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cue failures [profile] [--days N] [--json]`
|
|
3
|
+
*
|
|
4
|
+
* Scans ~/.config/cue/session-log.jsonl (written by the session-summary
|
|
5
|
+
* Stop hook) AND recent Claude Code transcripts under ~/.claude/projects/
|
|
6
|
+
* for failure markers — repeated tool errors, "let me try again" phrases,
|
|
7
|
+
* quality-gate vetoes, tests-failed messages.
|
|
8
|
+
*
|
|
9
|
+
* Outputs grouped by profile so you can see "where does this profile
|
|
10
|
+
* actually struggle?" The feed for Phase 5 of the maturity ladder: failure
|
|
11
|
+
* patterns become input to profile improvements.
|
|
12
|
+
*
|
|
13
|
+
* This is INSPECTION ONLY — it doesn't modify profiles. The improvement
|
|
14
|
+
* step is still manual (or a future `cue failures --propose` could draft
|
|
15
|
+
* skill additions).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, existsSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
|
|
23
|
+
import { findRealClaudeBin } from "../lib/claude-binary";
|
|
24
|
+
|
|
25
|
+
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
|
|
26
|
+
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
|
27
|
+
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
|
28
|
+
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
|
29
|
+
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
|
30
|
+
|
|
31
|
+
const SESSION_LOG = join(
|
|
32
|
+
process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
|
|
33
|
+
"cue", "session-log.jsonl",
|
|
34
|
+
);
|
|
35
|
+
const PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
36
|
+
|
|
37
|
+
interface SessionEntry {
|
|
38
|
+
ts: string;
|
|
39
|
+
cwd: string;
|
|
40
|
+
profile: string;
|
|
41
|
+
session_id: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface FailureMarker {
|
|
45
|
+
kind: "quality-gate-veto" | "tool-error" | "retry-loop" | "test-fail" | "manual-rollback";
|
|
46
|
+
pattern: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MARKERS: Array<{ kind: FailureMarker["kind"]; pattern: RegExp; label: string }> = [
|
|
50
|
+
{ kind: "quality-gate-veto", pattern: /cue:quality-gates BLOCKED Stop/i, label: "Quality gate vetoed Stop" },
|
|
51
|
+
{ kind: "tool-error", pattern: /Tool .* failed:|Error: ENOENT|Error: command failed|tool_use_error/i, label: "Tool errored" },
|
|
52
|
+
{ kind: "retry-loop", pattern: /let me try (again|that again|differently)|let's try again|i'll try again/i, label: "Agent self-retried" },
|
|
53
|
+
{ kind: "test-fail", pattern: /(test|spec).*(FAIL|failed)|\d+ fail/i, label: "Tests failed" },
|
|
54
|
+
{ kind: "manual-rollback", pattern: /git reset --hard|undo (that|my changes)|never mind/i, label: "Rolled back work" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function readSessionLog(daysBack: number): SessionEntry[] {
|
|
58
|
+
if (!existsSync(SESSION_LOG)) return [];
|
|
59
|
+
const cutoff = Date.now() - daysBack * 24 * 3600 * 1000;
|
|
60
|
+
const out: SessionEntry[] = [];
|
|
61
|
+
for (const line of readFileSync(SESSION_LOG, "utf8").split("\n")) {
|
|
62
|
+
if (!line.trim()) continue;
|
|
63
|
+
try {
|
|
64
|
+
const e = JSON.parse(line) as SessionEntry;
|
|
65
|
+
if (!e.profile) continue;
|
|
66
|
+
if (new Date(e.ts).getTime() < cutoff) continue;
|
|
67
|
+
out.push(e);
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Lightly scan a transcript jsonl file for marker patterns. Returns one entry per marker hit. */
|
|
74
|
+
function scanTranscript(path: string): FailureMarker[] {
|
|
75
|
+
const out: FailureMarker[] = [];
|
|
76
|
+
let content: string;
|
|
77
|
+
try { content = readFileSync(path, "utf8"); } catch { return []; }
|
|
78
|
+
// Truncate to last 200KB to avoid huge old sessions dominating
|
|
79
|
+
if (content.length > 200_000) content = content.slice(-200_000);
|
|
80
|
+
for (const m of MARKERS) {
|
|
81
|
+
const matches = content.match(new RegExp(m.pattern, "gi"));
|
|
82
|
+
if (matches) {
|
|
83
|
+
// Take up to 3 distinct hits per marker per transcript
|
|
84
|
+
const seen = new Set<string>();
|
|
85
|
+
for (const hit of matches) {
|
|
86
|
+
const key = hit.slice(0, 60);
|
|
87
|
+
if (seen.has(key)) continue;
|
|
88
|
+
seen.add(key);
|
|
89
|
+
out.push({ kind: m.kind, pattern: m.label });
|
|
90
|
+
if (seen.size >= 3) break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Find ~120-char excerpts around each marker match in a transcript. Used by
|
|
99
|
+
* --propose to give the LLM enough context to suggest concrete fixes without
|
|
100
|
+
* sending the full transcript (which would be huge + privacy-sensitive).
|
|
101
|
+
*/
|
|
102
|
+
function scanTranscriptExcerpts(path: string, maxPerPattern: number): Array<{ pattern: string; excerpt: string }> {
|
|
103
|
+
const out: Array<{ pattern: string; excerpt: string }> = [];
|
|
104
|
+
let content: string;
|
|
105
|
+
try { content = readFileSync(path, "utf8"); } catch { return []; }
|
|
106
|
+
if (content.length > 200_000) content = content.slice(-200_000);
|
|
107
|
+
for (const m of MARKERS) {
|
|
108
|
+
const re = new RegExp(m.pattern, "gi");
|
|
109
|
+
let match: RegExpExecArray | null;
|
|
110
|
+
let count = 0;
|
|
111
|
+
while ((match = re.exec(content)) && count < maxPerPattern) {
|
|
112
|
+
const start = Math.max(0, match.index - 60);
|
|
113
|
+
const end = Math.min(content.length, match.index + match[0].length + 60);
|
|
114
|
+
// Strip raw JSONL noise — just want a short snippet
|
|
115
|
+
const excerpt = content.slice(start, end).replace(/\s+/g, " ").trim();
|
|
116
|
+
out.push({ pattern: m.label, excerpt });
|
|
117
|
+
count++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Most-recently-modified N transcripts under ~/.claude/projects/<*>/<*>.jsonl */
|
|
124
|
+
function recentTranscripts(daysBack: number, limit: number): string[] {
|
|
125
|
+
if (!existsSync(PROJECTS_DIR)) return [];
|
|
126
|
+
const cutoff = Date.now() - daysBack * 24 * 3600 * 1000;
|
|
127
|
+
const out: { path: string; mtime: number }[] = [];
|
|
128
|
+
try {
|
|
129
|
+
for (const proj of readdirSync(PROJECTS_DIR)) {
|
|
130
|
+
const projDir = join(PROJECTS_DIR, proj);
|
|
131
|
+
try {
|
|
132
|
+
for (const f of readdirSync(projDir)) {
|
|
133
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
134
|
+
const full = join(projDir, f);
|
|
135
|
+
try {
|
|
136
|
+
const st = statSync(full);
|
|
137
|
+
if (st.mtimeMs >= cutoff) out.push({ path: full, mtime: st.mtimeMs });
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
} catch {}
|
|
143
|
+
out.sort((a, b) => b.mtime - a.mtime);
|
|
144
|
+
return out.slice(0, limit).map((x) => x.path);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function run(args: string[]): Promise<number> {
|
|
148
|
+
const asJson = args.includes("--json");
|
|
149
|
+
const daysIdx = args.indexOf("--days");
|
|
150
|
+
const days = daysIdx >= 0 && args[daysIdx + 1] ? Math.max(1, parseInt(args[daysIdx + 1]!, 10) || 7) : 7;
|
|
151
|
+
const propose = args.includes("--propose");
|
|
152
|
+
const profileFilter = args.find((a) => !a.startsWith("-") && a !== String(days));
|
|
153
|
+
|
|
154
|
+
if (propose) return runPropose({ days, profileFilter, asJson });
|
|
155
|
+
|
|
156
|
+
const sessions = readSessionLog(days);
|
|
157
|
+
const transcripts = recentTranscripts(days, 30);
|
|
158
|
+
|
|
159
|
+
// Map cwd → profile via session-log; some transcripts won't have a known profile.
|
|
160
|
+
const cwdToProfile = new Map<string, string>();
|
|
161
|
+
for (const s of sessions) cwdToProfile.set(s.cwd, s.profile);
|
|
162
|
+
|
|
163
|
+
// Aggregate failures per profile
|
|
164
|
+
const byProfile = new Map<string, Map<string, number>>();
|
|
165
|
+
for (const path of transcripts) {
|
|
166
|
+
// Transcript dir name encodes cwd (with / → -); we can't perfectly invert it,
|
|
167
|
+
// so use the most-recent session entry as the profile guess for now.
|
|
168
|
+
const guessProfile = sessions[0]?.profile ?? "(unknown)";
|
|
169
|
+
if (profileFilter && guessProfile !== profileFilter) continue;
|
|
170
|
+
const markers = scanTranscript(path);
|
|
171
|
+
if (markers.length === 0) continue;
|
|
172
|
+
const labels = byProfile.get(guessProfile) ?? new Map<string, number>();
|
|
173
|
+
for (const m of markers) labels.set(m.pattern, (labels.get(m.pattern) ?? 0) + 1);
|
|
174
|
+
byProfile.set(guessProfile, labels);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (asJson) {
|
|
178
|
+
const out: any = { sinceDays: days, sessions: sessions.length, transcripts: transcripts.length, byProfile: {} };
|
|
179
|
+
for (const [p, m] of byProfile) out.byProfile[p] = Object.fromEntries(m);
|
|
180
|
+
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
process.stdout.write(`\n ${bold("Failure pattern review")} ${dim(`(last ${days} days)`)}\n\n`);
|
|
185
|
+
process.stdout.write(` Sessions logged: ${sessions.length} · Transcripts scanned: ${transcripts.length}\n\n`);
|
|
186
|
+
|
|
187
|
+
if (byProfile.size === 0) {
|
|
188
|
+
process.stdout.write(` ${green("✓")} No failure patterns detected. Either things are going well, or the session-summary hook isn't installed.\n\n`);
|
|
189
|
+
process.stdout.write(` ${dim("Install: add resources/hooks/session-summary.json to your profile's hooks list.")}\n\n`);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const [profile, markers] of byProfile) {
|
|
194
|
+
const total = [...markers.values()].reduce((s, n) => s + n, 0);
|
|
195
|
+
const sev = total >= 10 ? red(`${total} hits`) : total >= 3 ? yellow(`${total} hits`) : dim(`${total} hits`);
|
|
196
|
+
process.stdout.write(` ${bold(profile)} ${sev}\n`);
|
|
197
|
+
const sorted = [...markers.entries()].sort((a, b) => b[1] - a[1]);
|
|
198
|
+
for (const [label, count] of sorted) {
|
|
199
|
+
process.stdout.write(` ${dim("·")} ${String(count).padStart(3)} × ${label}\n`);
|
|
200
|
+
}
|
|
201
|
+
process.stdout.write("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
process.stdout.write(` ${dim("Want more detail per pattern? Run with --json and pipe to jq.")}\n\n`);
|
|
205
|
+
process.stdout.write(` ${dim("Or: cue failures --propose [profile] — ask Claude to draft profile changes from these failures.")}\n\n`);
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// --propose — read recent failures, ask Claude to draft profile improvements,
|
|
211
|
+
// write the proposal to ~/.config/cue/proposals/ for review. Never auto-applies.
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
const PROPOSALS_DIR = join(
|
|
215
|
+
process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
|
|
216
|
+
"cue", "proposals",
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
interface FailureBundle {
|
|
220
|
+
profile: string;
|
|
221
|
+
patternCounts: Map<string, number>;
|
|
222
|
+
excerpts: Array<{ pattern: string; excerpt: string }>; // sampled across transcripts
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function gatherFailureBundle(profileFilter: string | undefined, days: number): FailureBundle | null {
|
|
226
|
+
const sessions = readSessionLog(days);
|
|
227
|
+
const transcripts = recentTranscripts(days, 30);
|
|
228
|
+
if (transcripts.length === 0) return null;
|
|
229
|
+
|
|
230
|
+
// Guess profile from the most-recent session (matches the detector's logic).
|
|
231
|
+
const guessProfile = profileFilter ?? sessions[0]?.profile;
|
|
232
|
+
if (!guessProfile) return null;
|
|
233
|
+
|
|
234
|
+
const patternCounts = new Map<string, number>();
|
|
235
|
+
const excerpts: Array<{ pattern: string; excerpt: string }> = [];
|
|
236
|
+
let excerptCount = 0;
|
|
237
|
+
const MAX_EXCERPTS = 25;
|
|
238
|
+
|
|
239
|
+
for (const path of transcripts) {
|
|
240
|
+
if (profileFilter && sessions[0]?.profile !== profileFilter) continue;
|
|
241
|
+
for (const m of scanTranscript(path)) patternCounts.set(m.pattern, (patternCounts.get(m.pattern) ?? 0) + 1);
|
|
242
|
+
if (excerptCount < MAX_EXCERPTS) {
|
|
243
|
+
for (const ex of scanTranscriptExcerpts(path, 2)) {
|
|
244
|
+
if (excerptCount >= MAX_EXCERPTS) break;
|
|
245
|
+
excerpts.push(ex);
|
|
246
|
+
excerptCount++;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (patternCounts.size === 0) return null;
|
|
251
|
+
return { profile: guessProfile, patternCounts, excerpts };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildProposalPrompt(bundle: FailureBundle, profileSnapshot: string): string {
|
|
255
|
+
const patternLines = [...bundle.patternCounts.entries()]
|
|
256
|
+
.sort((a, b) => b[1] - a[1])
|
|
257
|
+
.map(([p, n]) => `- ${n}× ${p}`)
|
|
258
|
+
.join("\n");
|
|
259
|
+
const excerptLines = bundle.excerpts
|
|
260
|
+
.slice(0, 15)
|
|
261
|
+
.map((e, i) => `${i + 1}. [${e.pattern}]\n "${e.excerpt.slice(0, 200)}"`)
|
|
262
|
+
.join("\n\n");
|
|
263
|
+
|
|
264
|
+
return `You are reviewing a cue profile that's been struggling. Below is its current loadout and a sample of failure patterns from recent sessions. Your job: propose 3-5 concrete, minimal-change improvements that would prevent these failures.
|
|
265
|
+
|
|
266
|
+
# Profile: ${bundle.profile}
|
|
267
|
+
|
|
268
|
+
\`\`\`yaml
|
|
269
|
+
${profileSnapshot}
|
|
270
|
+
\`\`\`
|
|
271
|
+
|
|
272
|
+
# Failure patterns (last week)
|
|
273
|
+
|
|
274
|
+
${patternLines}
|
|
275
|
+
|
|
276
|
+
# Sample excerpts from failed sessions
|
|
277
|
+
|
|
278
|
+
${excerptLines}
|
|
279
|
+
|
|
280
|
+
# Your task
|
|
281
|
+
|
|
282
|
+
Propose 3-5 concrete profile changes. For each:
|
|
283
|
+
1. **Change type** — one of: add-skill, add-rule, add-playbook, tighten-persona, add-quality-gate, remove-skill
|
|
284
|
+
2. **Rationale** — which specific failure pattern this addresses (cite the count)
|
|
285
|
+
3. **Concrete YAML diff** — a snippet that can be pasted into the profile.yaml
|
|
286
|
+
|
|
287
|
+
Format each proposal as:
|
|
288
|
+
|
|
289
|
+
## Proposal N: <short title>
|
|
290
|
+
**Type:** add-skill | add-rule | etc.
|
|
291
|
+
**Addresses:** <pattern> (N occurrences)
|
|
292
|
+
**Rationale:** <1-2 sentences>
|
|
293
|
+
**YAML diff:**
|
|
294
|
+
\`\`\`yaml
|
|
295
|
+
# add to profile.yaml:
|
|
296
|
+
<the change>
|
|
297
|
+
\`\`\`
|
|
298
|
+
|
|
299
|
+
Constraints:
|
|
300
|
+
- Don't propose anything that requires the user to write new code/files unless absolutely necessary.
|
|
301
|
+
- Prefer reusing existing skills/rules/playbooks in cue's resources/ directory.
|
|
302
|
+
- Be specific. "Add a testing skill" is bad; "Add nvidia/skill-evolution skill which catches X" is good.
|
|
303
|
+
- If a failure pattern can't be helped by a profile change, say so explicitly.
|
|
304
|
+
`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function runPropose(opts: { days: number; profileFilter: string | undefined; asJson: boolean }): Promise<number> {
|
|
308
|
+
const bundle = gatherFailureBundle(opts.profileFilter, opts.days);
|
|
309
|
+
if (!bundle) {
|
|
310
|
+
process.stderr.write(`No failures detected in last ${opts.days} days${opts.profileFilter ? ` for profile "${opts.profileFilter}"` : ""}.\n`);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Load the profile snapshot (description + skills + rules etc) for context.
|
|
315
|
+
let profileSnapshot = "";
|
|
316
|
+
try {
|
|
317
|
+
const { loadProfile } = await import("../lib/profile-loader");
|
|
318
|
+
const p = await loadProfile(bundle.profile);
|
|
319
|
+
profileSnapshot = JSON.stringify({
|
|
320
|
+
name: p.name,
|
|
321
|
+
description: p.description,
|
|
322
|
+
persona: (p as any).persona ?? "",
|
|
323
|
+
skills: p.skills.local.map((s) => s.id),
|
|
324
|
+
rules: p.rules,
|
|
325
|
+
commands: p.commands,
|
|
326
|
+
playbooks: (p as any).playbooks ?? [],
|
|
327
|
+
qualityGates: (p as any).qualityGates ?? [],
|
|
328
|
+
}, null, 2);
|
|
329
|
+
} catch (e) {
|
|
330
|
+
process.stderr.write(`Could not load profile "${bundle.profile}": ${e}\n`);
|
|
331
|
+
return 1;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const prompt = buildProposalPrompt(bundle, profileSnapshot);
|
|
335
|
+
|
|
336
|
+
// Use the `claude` CLI in one-shot mode (-p / --print). The user's existing
|
|
337
|
+
// auth is reused. No new dependency added.
|
|
338
|
+
// Find the REAL claude binary, skipping cue's shim. The shim re-routes
|
|
339
|
+
// through `cue launch claude` which would either picker-loop or recurse.
|
|
340
|
+
const claudeBin = findRealClaudeBin();
|
|
341
|
+
if (!claudeBin) {
|
|
342
|
+
process.stderr.write(`✗\nCould not find a real \`claude\` binary (only found cue's shim).\n`);
|
|
343
|
+
process.stderr.write(`Install Claude Code: https://docs.claude.com/en/docs/claude-code\n`);
|
|
344
|
+
return 1;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
process.stderr.write(`📝 Asking Claude to draft profile improvements for "${bundle.profile}"... `);
|
|
348
|
+
const t0 = Date.now();
|
|
349
|
+
// Strip CUE_LAUNCHING + CLAUDE_CONFIG_DIR so claude runs cleanly even when
|
|
350
|
+
// this process is inside a cue-managed session.
|
|
351
|
+
const childEnv = { ...process.env };
|
|
352
|
+
delete childEnv.CUE_LAUNCHING;
|
|
353
|
+
delete childEnv.CLAUDE_CONFIG_DIR;
|
|
354
|
+
const res = spawnSync(claudeBin, ["-p", prompt], {
|
|
355
|
+
encoding: "utf8",
|
|
356
|
+
timeout: 120000,
|
|
357
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
358
|
+
env: childEnv,
|
|
359
|
+
});
|
|
360
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
361
|
+
|
|
362
|
+
if (res.status !== 0) {
|
|
363
|
+
process.stderr.write(`✗\n`);
|
|
364
|
+
process.stderr.write(`claude failed (exit ${res.status}): ${res.stderr.trim() || "(no stderr)"}\n`);
|
|
365
|
+
process.stderr.write(`Make sure the \`claude\` CLI is installed and authed. Try \`claude /login\` first.\n`);
|
|
366
|
+
return 1;
|
|
367
|
+
}
|
|
368
|
+
process.stderr.write(`✓ (${elapsed}s)\n`);
|
|
369
|
+
|
|
370
|
+
mkdirSync(PROPOSALS_DIR, { recursive: true });
|
|
371
|
+
const fname = `${bundle.profile}-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}.md`;
|
|
372
|
+
const path = join(PROPOSALS_DIR, fname);
|
|
373
|
+
const header = `# cue profile improvement proposal — ${bundle.profile}\n\n` +
|
|
374
|
+
`_Generated: ${new Date().toISOString()}_\n` +
|
|
375
|
+
`_Scanned: last ${opts.days} days · ${[...bundle.patternCounts.values()].reduce((s, n) => s + n, 0)} failure hits across ${bundle.patternCounts.size} patterns_\n\n` +
|
|
376
|
+
`> **Review carefully before applying.** Claude drafted these from limited context. Cross-check each YAML diff against your real failure mode before pasting it into the profile.\n\n` +
|
|
377
|
+
`---\n\n`;
|
|
378
|
+
writeFileSync(path, header + res.stdout);
|
|
379
|
+
|
|
380
|
+
if (opts.asJson) {
|
|
381
|
+
process.stdout.write(JSON.stringify({
|
|
382
|
+
proposal: path,
|
|
383
|
+
profile: bundle.profile,
|
|
384
|
+
patterns: Object.fromEntries(bundle.patternCounts),
|
|
385
|
+
bytes: res.stdout.length,
|
|
386
|
+
}, null, 2) + "\n");
|
|
387
|
+
} else {
|
|
388
|
+
process.stdout.write(`\n Proposal written to: ${bold(path)}\n\n`);
|
|
389
|
+
process.stdout.write(` ${dim("Review it, then apply the changes you like by editing profile.yaml.")}\n`);
|
|
390
|
+
process.stdout.write(` ${dim("Nothing was auto-applied — your profile is unchanged.")}\n\n`);
|
|
391
|
+
}
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cue feedback` — collect lightweight opt-in feedback from users.
|
|
3
|
+
*
|
|
4
|
+
* - Stored locally at ~/.config/cue/feedback.log (one JSON line per submission)
|
|
5
|
+
* - On first opt-in submission, asks if the user wants to share it as a GitHub
|
|
6
|
+
* issue on opencue/cue. Always opt-in; never automatic.
|
|
7
|
+
* - Three questions: how-found, what-using-for, what-blocked.
|
|
8
|
+
*
|
|
9
|
+
* Run with --view to dump local entries. Run with --share <id> to open a
|
|
10
|
+
* gh issue from a past entry.
|
|
11
|
+
*
|
|
12
|
+
* Privacy: nothing is sent anywhere without the user pressing through a
|
|
13
|
+
* confirm prompt. The local log lives on the user's disk only.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { createInterface } from "node:readline/promises";
|
|
21
|
+
|
|
22
|
+
const LOG_PATH = join(
|
|
23
|
+
process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
|
|
24
|
+
"cue",
|
|
25
|
+
"feedback.log",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
interface FeedbackEntry {
|
|
29
|
+
id: string;
|
|
30
|
+
ts: string;
|
|
31
|
+
cue_version: string;
|
|
32
|
+
how_found?: string;
|
|
33
|
+
using_for?: string;
|
|
34
|
+
what_blocked?: string;
|
|
35
|
+
shared_issue_url?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
|
|
39
|
+
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
|
40
|
+
const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
|
|
41
|
+
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
|
42
|
+
|
|
43
|
+
function genId(): string {
|
|
44
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getCueVersion(): string {
|
|
48
|
+
try {
|
|
49
|
+
const pkg = JSON.parse(readFileSync(join(import.meta.dirname ?? __dirname, "..", "..", "package.json"), "utf8"));
|
|
50
|
+
return pkg.version ?? "unknown";
|
|
51
|
+
} catch { return "unknown"; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadEntries(): FeedbackEntry[] {
|
|
55
|
+
if (!existsSync(LOG_PATH)) return [];
|
|
56
|
+
return readFileSync(LOG_PATH, "utf8")
|
|
57
|
+
.split("\n")
|
|
58
|
+
.filter(l => l.trim())
|
|
59
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
60
|
+
.filter((e): e is FeedbackEntry => !!e);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function saveEntry(entry: FeedbackEntry): void {
|
|
64
|
+
mkdirSync(dirname(LOG_PATH), { recursive: true });
|
|
65
|
+
appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function ask(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
|
69
|
+
const answer = await rl.question(`\n ${bold(question)}\n ${dim("(press Enter to skip)")} > `);
|
|
70
|
+
return answer.trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function viewCmd(): Promise<number> {
|
|
74
|
+
const entries = loadEntries();
|
|
75
|
+
if (entries.length === 0) {
|
|
76
|
+
process.stdout.write(`\n ${dim("No feedback entries yet.")} Run ${bold("cue feedback")} to add one.\n\n`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
process.stdout.write(`\n ${bold(`${entries.length} feedback entries`)} ${dim("(local — never sent unless you explicitly share)")}\n\n`);
|
|
80
|
+
for (const e of entries) {
|
|
81
|
+
process.stdout.write(` ${cyan(e.id)} ${dim(e.ts)} ${dim("cue " + e.cue_version)}\n`);
|
|
82
|
+
if (e.how_found) process.stdout.write(` ${dim("how found:")} ${e.how_found}\n`);
|
|
83
|
+
if (e.using_for) process.stdout.write(` ${dim("using for:")} ${e.using_for}\n`);
|
|
84
|
+
if (e.what_blocked) process.stdout.write(` ${dim("blocked:")} ${e.what_blocked}\n`);
|
|
85
|
+
if (e.shared_issue_url) process.stdout.write(` ${green("shared:")} ${e.shared_issue_url}\n`);
|
|
86
|
+
process.stdout.write("\n");
|
|
87
|
+
}
|
|
88
|
+
process.stdout.write(` ${dim(`Local log:`)} ${LOG_PATH}\n\n`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildIssueBody(e: FeedbackEntry): string {
|
|
93
|
+
return `> Feedback submitted via \`cue feedback --share\`. Posted with the user's explicit consent.
|
|
94
|
+
|
|
95
|
+
**cue version**: ${e.cue_version}
|
|
96
|
+
**timestamp**: ${e.ts}
|
|
97
|
+
|
|
98
|
+
### How did you find cue?
|
|
99
|
+
${e.how_found || "_(skipped)_"}
|
|
100
|
+
|
|
101
|
+
### What are you using cue for?
|
|
102
|
+
${e.using_for || "_(skipped)_"}
|
|
103
|
+
|
|
104
|
+
### What's blocking you / what's missing?
|
|
105
|
+
${e.what_blocked || "_(skipped)_"}
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
<sub>Anonymous submission from \`cue feedback\`. The user chose to share this as a GitHub issue; nothing was sent automatically.</sub>`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function shareCmd(args: string[]): Promise<number> {
|
|
113
|
+
const id = args.find(a => !a.startsWith("-"));
|
|
114
|
+
const entries = loadEntries();
|
|
115
|
+
const entry = id
|
|
116
|
+
? entries.find(e => e.id === id)
|
|
117
|
+
: entries[entries.length - 1];
|
|
118
|
+
if (!entry) {
|
|
119
|
+
process.stderr.write(`No feedback entry found${id ? ` for id "${id}"` : ""}.\n`);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
if (entry.shared_issue_url) {
|
|
123
|
+
process.stdout.write(` ${dim("Already shared:")} ${entry.shared_issue_url}\n`);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
if (!hasGh()) {
|
|
127
|
+
process.stderr.write("gh CLI not found. Install: https://cli.github.com/\n");
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
const title = `Feedback — cue ${entry.cue_version} (${entry.id})`;
|
|
131
|
+
const body = buildIssueBody(entry);
|
|
132
|
+
const dryRun = args.includes("--dry-run");
|
|
133
|
+
if (dryRun) {
|
|
134
|
+
process.stdout.write(`\n ${bold("Would post to opencue/cue:")}\n\n ${bold("Title:")} ${title}\n\n ${bold("Body:")}\n${body}\n\n ${dim("(dry-run — pass without --dry-run to post)")}\n\n`);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
const res = spawnSync("gh", [
|
|
138
|
+
"issue", "create",
|
|
139
|
+
"--repo", "opencue/cue",
|
|
140
|
+
"--title", title,
|
|
141
|
+
"--body", body,
|
|
142
|
+
"--label", "feedback",
|
|
143
|
+
], { encoding: "utf8", timeout: 15000 });
|
|
144
|
+
if (res.status !== 0) {
|
|
145
|
+
process.stderr.write(`Failed to post issue: ${res.stderr?.trim()}\n`);
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
const url = res.stdout.trim();
|
|
149
|
+
entry.shared_issue_url = url;
|
|
150
|
+
// Rewrite log with the URL added (replace the matching line).
|
|
151
|
+
const all = loadEntries().map(e => e.id === entry.id ? entry : e);
|
|
152
|
+
writeFileSync(LOG_PATH, all.map(e => JSON.stringify(e)).join("\n") + "\n");
|
|
153
|
+
process.stdout.write(` ${green("✓ Shared:")} ${url}\n`);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function hasGh(): boolean {
|
|
158
|
+
return spawnSync("gh", ["--version"], { stdio: "ignore", timeout: 1000 }).status === 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function run(args: string[]): Promise<number> {
|
|
162
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
163
|
+
process.stdout.write(`cue feedback — share what's working and what isn't
|
|
164
|
+
|
|
165
|
+
Usage:
|
|
166
|
+
cue feedback Submit a new feedback entry (local only)
|
|
167
|
+
cue feedback --view Show all local entries
|
|
168
|
+
cue feedback --share [id] Share a past entry as a GitHub issue (consent required)
|
|
169
|
+
cue feedback --share [id] --dry-run Preview the issue body, don't post
|
|
170
|
+
|
|
171
|
+
All entries are stored locally at:
|
|
172
|
+
${LOG_PATH}
|
|
173
|
+
|
|
174
|
+
Entries are NEVER sent anywhere without your explicit consent. The --share
|
|
175
|
+
flag opens a GitHub issue on opencue/cue under your own gh identity.
|
|
176
|
+
|
|
177
|
+
Examples:
|
|
178
|
+
cue feedback # walks through 3 questions
|
|
179
|
+
cue feedback --view # see what you've submitted
|
|
180
|
+
cue feedback --share # share the most recent entry
|
|
181
|
+
`);
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (args.includes("--view")) return viewCmd();
|
|
186
|
+
if (args.includes("--share")) return shareCmd(args.slice(args.indexOf("--share") + 1));
|
|
187
|
+
|
|
188
|
+
process.stdout.write(`\n ${bold("cue feedback")} ${dim("· local-only, opt-in to share")}\n`);
|
|
189
|
+
process.stdout.write(` ${dim("Three short questions. Skip any with Enter. Nothing is sent unless you run --share.")}\n`);
|
|
190
|
+
|
|
191
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
192
|
+
try {
|
|
193
|
+
const how_found = await ask(rl, "1. How did you find cue?");
|
|
194
|
+
const using_for = await ask(rl, "2. What are you using cue for? (one or two sentences)");
|
|
195
|
+
const what_blocked = await ask(rl, "3. What's blocking you, or what's missing? (any pain point)");
|
|
196
|
+
|
|
197
|
+
const entry: FeedbackEntry = {
|
|
198
|
+
id: genId(),
|
|
199
|
+
ts: new Date().toISOString(),
|
|
200
|
+
cue_version: getCueVersion(),
|
|
201
|
+
...(how_found ? { how_found } : {}),
|
|
202
|
+
...(using_for ? { using_for } : {}),
|
|
203
|
+
...(what_blocked ? { what_blocked } : {}),
|
|
204
|
+
};
|
|
205
|
+
saveEntry(entry);
|
|
206
|
+
process.stdout.write(`\n ${green("✓ Saved locally")} ${dim(`(id: ${entry.id})`)}\n`);
|
|
207
|
+
process.stdout.write(` ${dim("Path:")} ${LOG_PATH}\n\n`);
|
|
208
|
+
process.stdout.write(` ${bold("Want to share this as a GitHub issue on opencue/cue?")}\n`);
|
|
209
|
+
const share = await rl.question(` ${dim("Posts under your gh identity. y/N > ")}`);
|
|
210
|
+
if (share.trim().toLowerCase().startsWith("y")) {
|
|
211
|
+
await shareCmd([entry.id]);
|
|
212
|
+
} else {
|
|
213
|
+
process.stdout.write(` ${dim("Skipped. You can share later with:")} ${bold(`cue feedback --share ${entry.id}`)}\n\n`);
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
rl.close();
|
|
217
|
+
}
|
|
218
|
+
return 0;
|
|
219
|
+
}
|