@nanhara/hara 0.119.1 → 0.120.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 CHANGED
@@ -5,6 +5,28 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.120.0 — `hara feedback`: one door for humans and agents
9
+
10
+ - **`hara feedback "what happened"`** files a structured GitHub issue (repo hara-cli/hara,
11
+ label `feedback`) via the gh CLI, with a copy-paste fallback. Auto-collects hara version /
12
+ OS / Node / provider:model (never keys) and aggressively redacts credentials
13
+ (sk-/ghp_/AWS/Bearer/JWT/key=value families). `--session` opt-in attaches a redacted session
14
+ tail (issues are public); `--dry-run` prints without filing. Matching .github issue forms
15
+ ship in the repo — hand-filed and command-filed reports read identically.
16
+ - **Security stance (documented)**: hara agents exchange structured data — they never accept
17
+ task instructions from untrusted parties. Cross-trust-boundary agent task-passing is
18
+ permanently out of scope; feedback/issues are the entire agent-to-agent surface.
19
+
20
+ ## 0.119.2 — TUI: update notices actually visible · CJK-correct input wrapping
21
+
22
+ - **Update notices now render INSIDE the TUI** (yellow line under the header card). They used to
23
+ print to stdout before ink mounted and vanished when the TUI took the screen — which is why TUI
24
+ users never saw them and versions silently went stale (field report: stuck on 0.112.5).
25
+ - **Input wrapping measures terminal CELLS, not characters.** CJK/emoji render 2 cells wide;
26
+ mixed 中文+ASCII prompts used to overflow the real width and get soft-wrapped a second time
27
+ mid-word ("output" torn into "ou/tput"). Long words hard-break per code point so a double-width
28
+ char never straddles the terminal edge.
29
+
8
30
  ## 0.119.1 — field-feedback robustness: param gate · no-hang git · actionable timeouts · stale-artifact rule
9
31
 
10
32
  - **Required-parameter gate.** A tool call arriving WITHOUT its required parameters (observed:
@@ -0,0 +1,55 @@
1
+ // `hara feedback` — the hara-hub verdict made flesh: feedback is a COMMAND, not a server.
2
+ // Collects environment facts, redacts secrets, and builds a structured GitHub issue body that
3
+ // humans and agents file through the same door (gh CLI when present, copy-paste text otherwise).
4
+ // The session tail is OFF by default and explicitly opt-in (--session) because issues are public.
5
+ import { platform, release, arch } from "node:os";
6
+ export function collectEnv(version, model) {
7
+ return {
8
+ version,
9
+ os: `${platform()} ${release()} ${arch()}`,
10
+ node: process.version,
11
+ model,
12
+ };
13
+ }
14
+ /** Strip credential-looking material from text that is about to become a PUBLIC issue.
15
+ * Deliberately aggressive: false positives cost a little readability, false negatives leak keys. */
16
+ export function redact(text) {
17
+ return text
18
+ .replace(/\bsk-[A-Za-z0-9_-]{8,}/g, "sk-***")
19
+ .replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "gh*_***")
20
+ .replace(/\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, "AWS-KEY-***")
21
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer ***")
22
+ .replace(/\b(api[_-]?key|apikey|token|secret|password|passwd|authorization)(["']?\s*[:=]\s*["']?)[^\s"',;]{6,}/gi, "$1$2***")
23
+ .replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "JWT-***");
24
+ }
25
+ /** The structured issue body — same shape as .github/ISSUE_TEMPLATE/bug_report.yml so hand-filed
26
+ * and command-filed issues read identically to the triage side. */
27
+ export function buildIssueBody(description, env, sessionTail) {
28
+ const parts = [
29
+ "## What happened",
30
+ "",
31
+ redact(description.trim() || "(no description provided)"),
32
+ "",
33
+ "## Environment",
34
+ "",
35
+ "| | |",
36
+ "|---|---|",
37
+ `| hara | ${env.version} |`,
38
+ `| os | ${env.os} |`,
39
+ `| node | ${env.node} |`,
40
+ ...(env.model ? [`| model | ${redact(env.model)} |`] : []),
41
+ "",
42
+ ];
43
+ if (sessionTail && sessionTail.trim()) {
44
+ parts.push("## Session tail (redacted, shared with --session)", "", "```", redact(sessionTail).slice(0, 4000), "```", "");
45
+ }
46
+ parts.push("---", "_filed via `hara feedback`_");
47
+ return parts.join("\n");
48
+ }
49
+ /** Issue title from the description: first line, trimmed, capped. */
50
+ export function issueTitle(description) {
51
+ const first = (description.trim().split("\n")[0] || "feedback").trim();
52
+ return first.length > 70 ? first.slice(0, 67) + "…" : first;
53
+ }
54
+ export const FEEDBACK_REPO = "hara-cli/hara";
55
+ export const NEW_ISSUE_URL = `https://github.com/${FEEDBACK_REPO}/issues/new`;
package/dist/index.js CHANGED
@@ -1990,6 +1990,52 @@ rolesCmd.action(() => {
1990
1990
  out(`${c.bold(r.id)}${r.model ? c.dim(` (${r.model})`) : ""} ${c.dim("owns: " + r.owns.join(", "))}\n ${r.description}\n`);
1991
1991
  }
1992
1992
  });
1993
+ program
1994
+ .command("feedback [description...]")
1995
+ .description("file a structured bug/feature report to GitHub (hara-cli/hara) — humans and agents use the same door")
1996
+ .option("--session", "append a REDACTED tail of the most recent session (the issue is PUBLIC)")
1997
+ .option("--dry-run", "print the issue body without filing")
1998
+ .action(async (parts, o) => {
1999
+ // the hara-hub verdict: feedback is a command, not a server — GitHub Issues is the bus
2000
+ const { collectEnv, buildIssueBody, issueTitle, FEEDBACK_REPO, NEW_ISSUE_URL } = await import("./feedback.js");
2001
+ const desc = (parts ?? []).join(" ").trim();
2002
+ if (!desc) {
2003
+ out(`Usage: hara feedback "what happened…" [--session] [--dry-run] — files a structured issue to ${FEEDBACK_REPO}\n`);
2004
+ return;
2005
+ }
2006
+ let tail;
2007
+ if (o.session) {
2008
+ const { listSessions, loadSession } = await import("./session/store.js");
2009
+ const metas = listSessions();
2010
+ const last = metas[0] ? loadSession(metas[0].id) : null;
2011
+ if (last) {
2012
+ tail = last.history
2013
+ .slice(-8)
2014
+ .map((m) => (m.role === "user" ? `user: ${m.content}` : m.role === "assistant" && m.text ? `assistant: ${m.text}` : ""))
2015
+ .filter(Boolean)
2016
+ .join("\n")
2017
+ .slice(-3000);
2018
+ out(c.yellow("⚠ --session attaches a redacted tail of your last session to a PUBLIC issue.\n"));
2019
+ }
2020
+ }
2021
+ const { readRawConfig } = await import("./config.js");
2022
+ const raw = readRawConfig();
2023
+ const modelLabel = raw.provider && raw.model ? `${raw.provider}:${raw.model}` : undefined;
2024
+ const body = buildIssueBody(desc, collectEnv(pkg.version, modelLabel), tail);
2025
+ if (o.dryRun) {
2026
+ out(body + "\n");
2027
+ return;
2028
+ }
2029
+ const { execFileSync } = await import("node:child_process");
2030
+ try {
2031
+ execFileSync("gh", ["--version"], { stdio: "ignore" });
2032
+ const url = execFileSync("gh", ["issue", "create", "--repo", FEEDBACK_REPO, "--title", issueTitle(desc), "--body", body, "--label", "feedback"], { encoding: "utf8" });
2033
+ out(c.green("✓ filed: ") + url.trim() + "\n");
2034
+ }
2035
+ catch {
2036
+ out(`\n(gh CLI unavailable or filing failed — copy everything below into ${NEW_ISSUE_URL})\n\n# ${issueTitle(desc)}\n\n${body}\n`);
2037
+ }
2038
+ });
1993
2039
  const skillsCmd = program.command("skills").description("manage skills (.hara/skills/<name>/SKILL.md)");
1994
2040
  skillsCmd
1995
2041
  .command("init")
@@ -2937,6 +2983,9 @@ program.action(async (opts) => {
2937
2983
  routeHost: __routeForHeader?.host,
2938
2984
  modelSource: __modelSource,
2939
2985
  visionModel: cfg.visionModel,
2986
+ // the pre-mount stdout notice (line ~2497) doesn't survive ink taking the screen — TUI users
2987
+ // never saw update notices and versions silently went stale (field report: stuck on 0.112.5)
2988
+ updateNotice: cfg.updateCheck ? (checkForUpdate(pkg.version) ?? undefined) : undefined,
2940
2989
  },
2941
2990
  visionNotice: __visionNotice,
2942
2991
  cycleApproval: (m) => cycleMode(m),
package/dist/tui/App.js CHANGED
@@ -231,7 +231,7 @@ function HeaderCard(props) {
231
231
  : row("profile", _jsx(Text, { children: props.profileId ? `personal:${props.profileId}` : "personal" }));
232
232
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", borderDimColor: true, paddingX: 1, alignSelf: "flex-start", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: accent(), bold: true, children: "◆ hara" }), _jsx(Text, { dimColor: true, children: ` v${version} · the agent that runs like an org` })] }), _jsx(Text, { children: " " }), identityRow, row("model", (_jsxs(Text, { children: [_jsx(Text, { children: modelLabel }), isOrg
233
233
  ? props.modelSource ? _jsx(Text, { dimColor: true, children: ` · from ${props.modelSource}` }) : null
234
- : props.visionModel ? _jsx(Text, { dimColor: true, children: modelLineSuffix(props.visionModel) }) : null, _jsx(Text, { children: " " }), _jsx(Text, { color: "green", children: "/model ↹" })] }))), row("cwd", (_jsxs(Text, { children: [_jsx(Text, { children: cwdShort }), agentsMdLoaded ? _jsx(Text, { dimColor: true, children: " · AGENTS.md" }) : null] }))), sessionShort ? row("session", _jsx(Text, { children: sessionShort })) : null] }), _jsx(Text, { dimColor: true, children: " Tip: @ attach file · ctrl+t transcript · ctrl+r reasoning · shift+tab approval · esc interrupt" })] }));
234
+ : props.visionModel ? _jsx(Text, { dimColor: true, children: modelLineSuffix(props.visionModel) }) : null, _jsx(Text, { children: " " }), _jsx(Text, { color: "green", children: "/model ↹" })] }))), row("cwd", (_jsxs(Text, { children: [_jsx(Text, { children: cwdShort }), agentsMdLoaded ? _jsx(Text, { dimColor: true, children: " · AGENTS.md" }) : null] }))), sessionShort ? row("session", _jsx(Text, { children: sessionShort })) : null] }), _jsx(Text, { dimColor: true, children: " Tip: @ attach file · ctrl+t transcript · ctrl+r reasoning · shift+tab approval · esc interrupt" }), props.updateNotice ? _jsx(Text, { color: "yellow", children: ` ⬆ ${props.updateNotice}` }) : null] }));
235
235
  }
236
236
  // Spinner verb: while a turn is running, prefer the in_progress todo's activeForm (or its text),
237
237
  // so the bottom line reads "▶ updating tests…" instead of an abstract "working". Falls back to
@@ -110,6 +110,35 @@ function segmentize(value) {
110
110
  parts.push({ text: value.slice(last), token: false });
111
111
  return parts;
112
112
  }
113
+ /** Terminal cell width of one code point: CJK/fullwidth/emoji render 2 cells, combining marks and
114
+ * joiners 0, everything else 1. Wrapping used to count `.length` (1 per char) — mixed CJK+ASCII
115
+ * input then overflowed the real terminal width and ink soft-wrapped a second time mid-word
116
+ * (field report: "output" torn into "ou/tput" while typing a URL + 中文 prompt). */
117
+ export function charCells(ch) {
118
+ const cp = ch.codePointAt(0);
119
+ if ((cp >= 0x300 && cp <= 0x36f) || cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f))
120
+ return 0; // combining/ZWJ/VS
121
+ if ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
122
+ (cp >= 0x2e80 && cp <= 0xa4cf) || // CJK radicals … Yi
123
+ (cp >= 0xa960 && cp <= 0xa97f) ||
124
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
125
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
126
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
127
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
128
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
129
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji
130
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK extension planes
131
+ )
132
+ return 2;
133
+ return 1;
134
+ }
135
+ /** Display width of a string in terminal cells (sum of charCells over code points). */
136
+ export function cells(s) {
137
+ let n = 0;
138
+ for (const ch of s)
139
+ n += charCells(ch);
140
+ return n;
141
+ }
113
142
  /** Wrap `value` into rows that each fit within `cols` cells, breaking on spaces where possible but
114
143
  * never inside an `[Image #N]` token. Deterministic (no reliance on ink's soft-wrap) so wrapped rows
115
144
  * align under a stable gutter and the cursor position is exact. Always returns at least one row. */
@@ -154,25 +183,28 @@ export function wrapRows(value, cols) {
154
183
  flush(aEnd);
155
184
  continue;
156
185
  }
157
- if (a.atomic || a.text.length <= width) {
186
+ const aCells = cells(a.text);
187
+ if (a.atomic || aCells <= width) {
158
188
  // A whole unit (image token OR a word chunk that fits within a full row): if it doesn't fit in
159
189
  // the remaining room and the row already has content, wrap to a fresh row FIRST — never split it.
160
- if (a.text.length > width - col && col > 0)
190
+ if (aCells > width - col && col > 0)
161
191
  flush(a.start);
162
- col += a.text.length; // an oversized atomic token may exceed width — acceptable (rare, kept whole)
192
+ col += aCells; // an oversized atomic token may exceed width — acceptable (rare, kept whole)
163
193
  if (col >= width)
164
194
  flush(aEnd);
165
195
  }
166
196
  else {
167
- // A single word longer than the whole width: hard-break it across rows.
197
+ // A single word longer than the whole width: hard-break it across rows — walking CODE POINTS
198
+ // and accumulating CELLS, so a double-width char never straddles the terminal edge.
168
199
  let s = a.start;
169
200
  if (col > 0)
170
201
  flush(s); // start the long word on a fresh row
171
- while (s < aEnd) {
172
- const room = width - col;
173
- const take = Math.min(room, aEnd - s);
174
- col += take;
175
- s += take;
202
+ for (const ch of a.text) {
203
+ const w = charCells(ch);
204
+ if (col + w > width && col > 0)
205
+ flush(s);
206
+ col += w;
207
+ s += ch.length;
176
208
  if (col >= width)
177
209
  flush(s);
178
210
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.119.1",
3
+ "version": "0.120.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"