howtofixcli 0.7.13 → 0.7.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-6FUWMLWO.js +1 -0
  5. package/dist/chunk-7CHDNTH6.js +1 -0
  6. package/dist/chunk-7DTQKDO4.js +2 -0
  7. package/dist/chunk-AGGIYXN7.js +2 -0
  8. package/dist/chunk-AUDMRGWF.js +189 -0
  9. package/dist/chunk-CPJKKHGH.js +1 -0
  10. package/dist/chunk-DAPXGSGD.js +12 -0
  11. package/dist/chunk-DOD7MUGX.js +1 -0
  12. package/dist/chunk-DV2BBBBJ.js +2 -0
  13. package/dist/chunk-EIBOS5KO.js +5 -0
  14. package/dist/chunk-EYWOVCSS.js +6 -0
  15. package/dist/chunk-FPXVDP32.js +8 -0
  16. package/dist/chunk-GXB7RZHH.js +1 -0
  17. package/dist/chunk-HT3MFLAV.js +34 -0
  18. package/dist/chunk-ILDDDLRC.js +1 -0
  19. package/dist/chunk-JFYVRWJI.js +2 -0
  20. package/dist/chunk-JMKK26AT.js +2 -0
  21. package/dist/chunk-LAHVHRCS.js +2 -0
  22. package/dist/chunk-LOOFOBTS.js +1 -0
  23. package/dist/chunk-LSJC73DG.js +1 -0
  24. package/dist/chunk-MOKUBKE7.js +1 -0
  25. package/dist/chunk-N345KWWK.js +1 -0
  26. package/dist/chunk-ONZLNFQ2.js +3 -0
  27. package/dist/chunk-ORJ2QZBG.js +3 -0
  28. package/dist/chunk-PCALBZOO.js +2 -0
  29. package/dist/chunk-SX5VE6XA.js +110 -0
  30. package/dist/chunk-TSEKPGK4.js +1 -0
  31. package/dist/chunk-UNK5HRQR.js +1 -0
  32. package/dist/chunk-UTYFLLSM.js +1 -0
  33. package/dist/chunk-V2RPZMLK.js +1 -0
  34. package/dist/chunk-V3R4FYTJ.js +1 -0
  35. package/dist/chunk-V656XVXV.js +8 -0
  36. package/dist/chunk-VLMQSD25.js +1 -0
  37. package/dist/chunk-WKUJ7CFO.js +1 -0
  38. package/dist/chunk-WOT6VMZA.js +1 -0
  39. package/dist/chunk-WUPU6LTD.js +4 -0
  40. package/dist/chunk-WX3V5P7U.js +5 -0
  41. package/dist/chunk-Y7YM5R75.js +5 -0
  42. package/dist/chunk-YW7NORH3.js +2 -0
  43. package/dist/main.js +99 -540
  44. package/package.json +17 -64
  45. package/dist/account-credential.js +0 -136
  46. package/dist/account-credential.test.js +0 -38
  47. package/dist/activity.js +0 -124
  48. package/dist/agent-defs.js +0 -237
  49. package/dist/agent-limiter.js +0 -68
  50. package/dist/agents.js +0 -554
  51. package/dist/anim.js +0 -361
  52. package/dist/args.js +0 -228
  53. package/dist/at-file.js +0 -28
  54. package/dist/brand-extension.js +0 -87
  55. package/dist/brand.js +0 -76
  56. package/dist/browser.js +0 -58
  57. package/dist/cache-report.js +0 -139
  58. package/dist/checkpoint.js +0 -437
  59. package/dist/claims.js +0 -167
  60. package/dist/cli-auth.js +0 -142
  61. package/dist/cli-checkin.js +0 -120
  62. package/dist/cli-credentials.js +0 -254
  63. package/dist/clipboard.js +0 -149
  64. package/dist/collapse.js +0 -129
  65. package/dist/commands.js +0 -294
  66. package/dist/compaction-guard.js +0 -81
  67. package/dist/context-extension.js +0 -17
  68. package/dist/dcp/commands.js +0 -172
  69. package/dist/dcp/compress-state.js +0 -139
  70. package/dist/dcp/compress.js +0 -329
  71. package/dist/dcp/dcp-extension.js +0 -295
  72. package/dist/dcp/dedup.js +0 -93
  73. package/dist/dcp/ids.js +0 -189
  74. package/dist/dcp/index.js +0 -25
  75. package/dist/dcp/middle-prune.js +0 -76
  76. package/dist/dcp/prompts.js +0 -68
  77. package/dist/dcp/protected-content.js +0 -221
  78. package/dist/dcp/protected.js +0 -161
  79. package/dist/dcp/prune.js +0 -418
  80. package/dist/dcp/state.js +0 -176
  81. package/dist/device-id.js +0 -90
  82. package/dist/device-id.test.js +0 -23
  83. package/dist/errors.js +0 -63
  84. package/dist/extensions.js +0 -48
  85. package/dist/fatal.js +0 -35
  86. package/dist/feed.js +0 -498
  87. package/dist/fs-atomic.js +0 -54
  88. package/dist/gateway-errors.js +0 -90
  89. package/dist/git.js +0 -230
  90. package/dist/goal-extension.js +0 -88
  91. package/dist/goal.js +0 -222
  92. package/dist/hooks-extension.js +0 -176
  93. package/dist/hooks.js +0 -443
  94. package/dist/howtofix-account.js +0 -735
  95. package/dist/hud-line.js +0 -153
  96. package/dist/hwid.js +0 -130
  97. package/dist/interrupt.js +0 -60
  98. package/dist/json-events.js +0 -50
  99. package/dist/level-style.js +0 -112
  100. package/dist/loop-guard.js +0 -95
  101. package/dist/lsp-tools.js +0 -32
  102. package/dist/lsp.js +0 -429
  103. package/dist/mcp-config.js +0 -85
  104. package/dist/mcp-lifecycle.js +0 -147
  105. package/dist/model-catalog.js +0 -217
  106. package/dist/model-facts.js +0 -35
  107. package/dist/model-resolve.js +0 -65
  108. package/dist/notes.js +0 -78
  109. package/dist/oauth-login.js +0 -568
  110. package/dist/paths.js +0 -61
  111. package/dist/permissions.js +0 -298
  112. package/dist/plan-mode.js +0 -363
  113. package/dist/preset-command.js +0 -227
  114. package/dist/preset-extension.js +0 -35
  115. package/dist/preset-mcp.js +0 -292
  116. package/dist/presets.js +0 -727
  117. package/dist/prompt-files.js +0 -103
  118. package/dist/prompt-text.js +0 -41
  119. package/dist/proxy.js +0 -137
  120. package/dist/quality/ast.js +0 -196
  121. package/dist/quality/cognitive.js +0 -83
  122. package/dist/quality/cyclomatic.js +0 -30
  123. package/dist/quality/halstead.js +0 -83
  124. package/dist/quality/index.js +0 -18
  125. package/dist/quality/length.js +0 -3
  126. package/dist/quality/maintainability.js +0 -15
  127. package/dist/quality/nesting.js +0 -57
  128. package/dist/quality/report.js +0 -803
  129. package/dist/quality/smells.js +0 -67
  130. package/dist/quality-tool.js +0 -63
  131. package/dist/read-only-commands.js +0 -597
  132. package/dist/repo-map.js +0 -292
  133. package/dist/rtk.js +0 -193
  134. package/dist/sandbox.js +0 -105
  135. package/dist/schema.js +0 -214
  136. package/dist/session-lifecycle.js +0 -19
  137. package/dist/session-retention.js +0 -111
  138. package/dist/session.js +0 -447
  139. package/dist/sessions.js +0 -100
  140. package/dist/settings.js +0 -148
  141. package/dist/skills.js +0 -35
  142. package/dist/slop-tool.js +0 -96
  143. package/dist/spend-cap.js +0 -43
  144. package/dist/subagent.js +0 -171
  145. package/dist/tar.js +0 -146
  146. package/dist/theme.js +0 -79
  147. package/dist/thinking.js +0 -45
  148. package/dist/todo.js +0 -114
  149. package/dist/tool-loadout.js +0 -97
  150. package/dist/tool-render.js +0 -769
  151. package/dist/tools.js +0 -176
  152. package/dist/ui.js +0 -4778
  153. package/dist/update.js +0 -551
  154. package/dist/usage-segment.js +0 -69
  155. package/dist/web-tools.js +0 -46
  156. package/dist/web.js +0 -265
  157. package/dist/welcome.js +0 -179
  158. package/dist/workflow-engine.js +0 -246
  159. package/dist/workflow-progress.js +0 -446
  160. package/dist/workflow-view.js +0 -195
  161. package/dist/workflow.js +0 -349
package/package.json CHANGED
@@ -1,8 +1,20 @@
1
1
  {
2
2
  "name": "howtofixcli",
3
- "version": "0.7.13",
4
- "description": "HowToFix coding agent. Workbench TUI, sessions, background agents.",
3
+ "version": "0.7.15",
5
4
  "type": "module",
5
+ "description": "HowToFix coding agent. Workbench TUI, sessions, background agents.",
6
+ "license": "UNLICENSED",
7
+ "homepage": "https://howtofix.id",
8
+ "author": "HowToFix",
9
+ "keywords": [
10
+ "coding-agent",
11
+ "cli",
12
+ "tui",
13
+ "howtofix"
14
+ ],
15
+ "engines": {
16
+ "node": ">=22.19"
17
+ },
6
18
  "bin": {
7
19
  "howtofix": "dist/main.js"
8
20
  },
@@ -11,72 +23,13 @@
11
23
  "README.md",
12
24
  "LICENSE"
13
25
  ],
14
- "scripts": {
15
- "build": "tsc -p tsconfig.json && node --input-type=module -e \"import { rmSync, cpSync } from 'node:fs'; rmSync('dist/presets', { recursive: true, force: true }); cpSync('presets', 'dist/presets', { recursive: true });\" && node scripts/set-executable.mjs",
16
- "start": "node dist/main.js",
17
- "typecheck": "tsc -p tsconfig.json --noEmit",
18
- "check": "node scripts/check-all.mjs",
19
- "check:hub": "node scripts/check-hub-install.mjs",
20
- "check:tar": "node scripts/check-tar.mjs --online",
21
- "quality": "node scripts/quality.mjs",
22
- "frame": "node scripts/render-frame.mjs",
23
- "frame:workflow": "node scripts/render-workflow-frame.mjs",
24
- "probe:commands": "python3 scripts/probe-slash-commands.py",
25
- "build:release": "node scripts/build-release.mjs",
26
- "version": "node scripts/sync-version.mjs",
27
- "slop": "node scripts/slop.mjs",
28
- "slop:commits": "node scripts/slop.mjs --commits",
29
- "hooks:install": "git config core.hooksPath .githooks && echo 'hooks installed from .githooks'",
30
- "check:sync": "node scripts/check-hub-sync.mjs --online",
31
- "check:level-color": "python3 scripts/probe-level-color.py",
32
- "check:skills-wire": "node scripts/check-skill-wire.mjs",
33
- "check:agents-live": "node scripts/check-subagent-live.mjs",
34
- "release:patch": "node scripts/release.mjs patch",
35
- "release:minor": "node scripts/release.mjs minor",
36
- "release:major": "node scripts/release.mjs major",
37
- "release:dry": "node scripts/release.mjs patch --dry",
38
- "test": "node --test test/*.test.mjs",
39
- "format": "prettier --write \"src/**/*.ts\" \"scripts/**/*.mjs\" \"test/**/*.mjs\"",
40
- "format:check": "prettier --check \"src/**/*.ts\" \"scripts/**/*.mjs\" \"test/**/*.mjs\""
41
- },
42
- "license": "UNLICENSED",
43
- "engines": {
44
- "node": ">=22.19"
45
- },
46
26
  "dependencies": {
47
27
  "@netrodev/hxf-agent-core": "^1.0.22",
48
28
  "@netrodev/hxf-ai": "^1.0.22",
49
29
  "@netrodev/hxf-coding-agent": "^1.0.27",
50
30
  "@netrodev/hxf-tui": "^1.0.22",
51
- "typebox": "1.3.27",
31
+ "typebox": "^1.4.3",
52
32
  "typescript": "7.0.2",
53
- "undici": "^8.10.2"
54
- },
55
- "devDependencies": {
56
- "@types/node": "^22.20.5",
57
- "esbuild": "^0.28.2",
58
- "prettier": "^3.9.10"
59
- },
60
- "homepage": "https://howtofix.id",
61
- "author": "HowToFix",
62
- "keywords": [
63
- "coding-agent",
64
- "cli",
65
- "tui",
66
- "howtofix"
67
- ],
68
- "contributors": [
69
- {
70
- "name": "hshinosa",
71
- "url": "https://github.com/hshinosa"
72
- },
73
- {
74
- "name": "yhagus",
75
- "url": "https://github.com/yhagus"
76
- },
77
- {
78
- "name": "mmatttttt",
79
- "url": "https://github.com/mmatttttt"
80
- }
81
- ]
33
+ "undici": "^8.12.3"
34
+ }
82
35
  }
@@ -1,136 +0,0 @@
1
- /**
2
- * Signing in with the account itself, not with a key made for the occasion.
3
- *
4
- * ## The problem this replaces
5
- *
6
- * `/connect` ended in a browser flow whose approval minted a fresh `sk-` key
7
- * every time. A user who reconnected a few times found a panel full of
8
- * near-identical rows, could not tell which one the CLI was using, and had to
9
- * revoke them one by one. The account was already the thing being proven; the
10
- * key was only a side effect of proving it.
11
- *
12
- * ## What happens instead
13
- *
14
- * The credential — `email:password`, base64-encoded — is stored once in the agent
15
- * directory, mode 600, and sent to `POST /api/auth/cli/credential`. The panel
16
- * verifies it and answers with the CLI key this account already owns; only an
17
- * account with no CLI key gets a new one. A headless install therefore needs no
18
- * second device and no pasted key.
19
- *
20
- * ## Why base64
21
- *
22
- * It is what people paste: one opaque string, no shell quoting, no `@` or `:` to
23
- * trip over. It is not encryption and is not presented as such — the file is
24
- * protected by its permissions, and the value is never printed.
25
- */
26
- import { chmodSync, existsSync, readFileSync } from "node:fs";
27
- import { join } from "node:path";
28
- import { writeFileAtomicSync } from "./fs-atomic.js";
29
- /** Where the stored credential lives. */
30
- export function accountCredentialPath(agentDir) {
31
- return join(agentDir, "account-credential.json");
32
- }
33
- /** base64 of `username:password`. */
34
- export function encodeCredential(username, password) {
35
- return Buffer.from(`${username}:${password}`, "utf8").toString("base64");
36
- }
37
- /**
38
- * Read either a base64 blob or a plain `user:pass` line.
39
- *
40
- * A pasted credential is almost always one of the two, and refusing the plain
41
- * form only makes the user encode it by hand to satisfy the parser.
42
- */
43
- export function parseCredentialInput(raw) {
44
- const trimmed = raw.trim();
45
- if (!trimmed)
46
- return null;
47
- const fromPlain = (value) => {
48
- const at = value.indexOf(":");
49
- if (at <= 0 || at === value.length - 1)
50
- return null;
51
- return { username: value.slice(0, at), password: value.slice(at + 1) };
52
- };
53
- const plain = fromPlain(trimmed);
54
- if (plain)
55
- return plain;
56
- // Not `user:pass`, so try base64 and re-read the decoded text.
57
- try {
58
- const decoded = Buffer.from(trimmed, "base64").toString("utf8");
59
- if (!decoded.includes(":"))
60
- return null;
61
- return fromPlain(decoded);
62
- }
63
- catch {
64
- return null;
65
- }
66
- }
67
- /** Store the credential so a later `/connect` needs nothing typed. */
68
- export function saveAccountCredential(agentDir, username, password) {
69
- try {
70
- const payload = {
71
- credential: encodeCredential(username, password),
72
- username,
73
- savedAt: Math.floor(Date.now() / 1000),
74
- };
75
- const target = accountCredentialPath(agentDir);
76
- writeFileAtomicSync(target, JSON.stringify(payload, null, 2));
77
- chmodSync(target, 0o600);
78
- return true;
79
- }
80
- catch {
81
- return false;
82
- }
83
- }
84
- /** The stored credential, if one was saved and is still readable. */
85
- export function loadAccountCredential(agentDir) {
86
- try {
87
- const target = accountCredentialPath(agentDir);
88
- if (!existsSync(target))
89
- return null;
90
- const parsed = JSON.parse(readFileSync(target, "utf8"));
91
- if (!parsed?.credential || !parsed?.username)
92
- return null;
93
- return parsed;
94
- }
95
- catch {
96
- return null;
97
- }
98
- }
99
- /** A JSON POST against the panel. */
100
- async function panelPost(url, body) {
101
- const response = await fetch(url, {
102
- method: "POST",
103
- headers: { "Content-Type": "application/json" },
104
- body: JSON.stringify(body),
105
- signal: AbortSignal.timeout(20_000),
106
- });
107
- const text = await response.text();
108
- let parsed;
109
- try {
110
- parsed = JSON.parse(text);
111
- }
112
- catch {
113
- throw new Error(`the panel answered ${response.status} with something that is not JSON`);
114
- }
115
- const envelope = parsed;
116
- if (!response.ok || envelope.success === false) {
117
- throw new Error(envelope.message || `the panel answered ${response.status}`);
118
- }
119
- return envelope.data;
120
- }
121
- /**
122
- * Exchange the account credential for the CLI key this account owns.
123
- *
124
- * The panel decides whether that key already existed; the CLI never creates a
125
- * token of its own, so a reconnect cannot add a row to the panel.
126
- */
127
- export async function loginWithCredential(panelUrl, username, password, clientInfo) {
128
- const data = await panelPost(`${panelUrl}/api/auth/cli/credential`, {
129
- credential: encodeCredential(username, password),
130
- client_info: clientInfo,
131
- });
132
- if (!data?.token) {
133
- throw new Error("the panel accepted the credential but returned no key");
134
- }
135
- return { token: data.token };
136
- }
@@ -1,38 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { test } from "node:test";
3
- import { encodeCredential, parseCredentialInput } from "./account-credential.js";
4
- test("reads a plain email:password credential", () => {
5
- assert.deepEqual(parseCredentialInput("netro@example.com:secret123"), {
6
- username: "netro@example.com",
7
- password: "secret123",
8
- });
9
- });
10
- test("reads the base64 form of the same credential", () => {
11
- const encoded = encodeCredential("netro@example.com", "secret123");
12
- assert.deepEqual(parseCredentialInput(encoded), {
13
- username: "netro@example.com",
14
- password: "secret123",
15
- });
16
- });
17
- test("keeps a password that contains a colon", () => {
18
- assert.deepEqual(parseCredentialInput("user:p:a:ss"), {
19
- username: "user",
20
- password: "p:a:ss",
21
- });
22
- });
23
- test("refuses a relay key", () => {
24
- assert.equal(parseCredentialInput("sk-abcdef0123456789"), null);
25
- });
26
- test("refuses text with no separator", () => {
27
- assert.equal(parseCredentialInput("just-a-word"), null);
28
- assert.equal(parseCredentialInput(""), null);
29
- });
30
- test("refuses an empty username or password", () => {
31
- assert.equal(parseCredentialInput(":secret"), null);
32
- assert.equal(parseCredentialInput("user:"), null);
33
- });
34
- test("encodeCredential is what parseCredentialInput decodes", () => {
35
- const encoded = encodeCredential("a@b.co", "p:w");
36
- assert.equal(Buffer.from(encoded, "base64").toString("utf8"), "a@b.co:p:w");
37
- assert.deepEqual(parseCredentialInput(encoded), { username: "a@b.co", password: "p:w" });
38
- });
package/dist/activity.js DELETED
@@ -1,124 +0,0 @@
1
- /**
2
- * The one line under the input box, made consistent.
3
- *
4
- * That line used to be a single `hud.activity` string written from twenty-three
5
- * places, each with its own idea of what it looked like:
6
- *
7
- * `⠋ thinking 12s` — spinner and a word
8
- * `✓ Read ×1 | Workflow ×1` — a tool tally
9
- * `* shell 4096 bytes` — a bullet
10
- * `API key for nrimo. Paste it, then press enter.` — a sentence
11
- * `""` — blank, from eight different places
12
- *
13
- * The result flickered between shapes and erased itself whenever an unrelated
14
- * event landed, which is what "the text keeps changing and is not consistent"
15
- * describes. The cause is not the number of writers; it is that each writer
16
- * owned the *whole* line.
17
- *
18
- * So writers now own a **layer**, and the line renders the highest-priority
19
- * non-empty one:
20
- *
21
- * prompt > retry > note > run > tally
22
- *
23
- * A prompt waiting for you outranks a transient note, a note outranks the live
24
- * run, and the tool tally is a floor that survives the run ending. Clearing a
25
- * layer only clears that layer: a finished run no longer wipes a message that
26
- * was waiting for the user.
27
- *
28
- * The retry sits above the note because the two describe the same silence — a
29
- * provider that did not answer — and the retry is the one that says what is
30
- * being done about it. A note that is still pinned while a retry counts down is
31
- * the older, vaguer half of the same story.
32
- *
33
- * Time-varying text is produced here, not stored, so the label stays put while
34
- * only the elapsed seconds tick.
35
- */
36
- export function emptyActivity() {
37
- return {};
38
- }
39
- /** Set or clear one layer. Returns a new state; the caller assigns it. */
40
- export function withLayer(state, layer, text, now = Date.now()) {
41
- const next = { ...state };
42
- if (layer === "run") {
43
- if (text === undefined || text === "")
44
- delete next.run;
45
- // Keep the original start when only the label changes, so a run that moves
46
- // from "thinking" to "running agents" does not reset its own clock.
47
- else
48
- next.run = { label: text, since: state.run?.since ?? now };
49
- return next;
50
- }
51
- if (text === undefined || text === "")
52
- delete next[layer];
53
- else
54
- next[layer] = text;
55
- return next;
56
- }
57
- /**
58
- * Set or clear the retry layer.
59
- *
60
- * Separate from `withLayer` because the value is a deadline rather than a
61
- * string: the caller hands over when the next attempt fires and what went
62
- * wrong, and the text is derived from the clock on every draw.
63
- */
64
- export function withRetry(state, retry) {
65
- const next = { ...state };
66
- if (retry === undefined)
67
- delete next.retry;
68
- else
69
- next.retry = retry;
70
- return next;
71
- }
72
- /** `42s` / `1m 05s`, the same shape the rest of the UI uses. */
73
- function formatElapsed(ms) {
74
- const seconds = Math.max(0, Math.round(ms / 1000));
75
- const minutes = Math.floor(seconds / 60);
76
- return minutes > 0 ? `${minutes}m ${String(seconds % 60).padStart(2, "0")}s` : `${seconds}s`;
77
- }
78
- /**
79
- * The provider's message, cut to what one line can hold.
80
- *
81
- * A failed request reports itself in a paragraph, sometimes several — a status
82
- * line, a JSON body, a stack. The activity line is one row wide, so the first
83
- * non-empty line is the part that says what happened.
84
- */
85
- export function firstLine(text, limit = 72) {
86
- const line = text.split("\n").map((part) => part.trim()).find((part) => part !== "") ?? "";
87
- return line.length > limit ? `${line.slice(0, limit - 1)}…` : line;
88
- }
89
- /**
90
- * `retry 1/3 in 2s · upstream 502` — the wait counts down as the clock moves.
91
- *
92
- * The countdown is the point: a bare `retry 1/3` says something is happening
93
- * but not whether to wait or to reach for ctrl+c, and a retry that has already
94
- * fired must not keep reading like a countdown. Past the deadline the text says
95
- * so and stays there until the attempt reports back.
96
- */
97
- function retryText(retry, now) {
98
- const left = Math.max(0, Math.ceil((retry.until - now) / 1000));
99
- const when = left > 0 ? `in ${left}s` : "now";
100
- const head = `retry ${retry.attempt}/${retry.maxAttempts} ${when}`;
101
- const reason = firstLine(retry.error);
102
- return reason === "" ? head : `${head} · ${reason}`;
103
- }
104
- /**
105
- * The line to draw, or `""` when nothing is happening.
106
- *
107
- * One marker per layer, one separator (` · `), and the text never carries its
108
- * own prefix — so two different states can never look like two different tools.
109
- */
110
- export function activityText(state, spinner, now = Date.now()) {
111
- if (state.prompt)
112
- return `? ${state.prompt}`;
113
- if (state.retry)
114
- return `* ${retryText(state.retry, now)}`;
115
- if (state.note)
116
- return `* ${state.note}`;
117
- if (state.run)
118
- return `${spinner} ${state.run.label} · ${formatElapsed(now - state.run.since)}`;
119
- // No marker of its own: the tally is already a list of per-tool markers
120
- // (`✓ Read ×1 | ! Workflow ×1`), so prefixing it drew `✓ ✓ Read ×1`.
121
- if (state.tally)
122
- return state.tally;
123
- return "";
124
- }
@@ -1,237 +0,0 @@
1
- import { readFileSync, readdirSync, statSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { getAgentDir, parseFrontmatter } from "@netrodev/hxf-coding-agent";
4
- import { BUILTIN_AGENT_NAMES } from "./agents.js";
5
- /**
6
- * Subagent definitions written by the user, as markdown.
7
- *
8
- * One file is one agent. The frontmatter carries what the model needs to choose
9
- * it and what the run needs to be set up, and the body becomes the child's
10
- * standing system prompt:
11
- *
12
- * ---
13
- * name: migration
14
- * description: moves one module at a time and tests after each
15
- * tools: read, bash, edit, write
16
- * model: gemini-3-flash
17
- * effort: medium
18
- * ---
19
- * Migrate exactly one module per turn. Run its tests before moving on, and
20
- * report the file you changed and the test result.
21
- *
22
- * The parser is the harness's own, so a file means the same thing here and in
23
- * the harness. The locations follow this CLI's rule: everything a user writes
24
- * for a project lives under one directory, `.howtofix`, the same one that holds
25
- * project skills and presets.
26
- *
27
- * <project>/.howtofix/agents/*.md nearest ancestor, found by walking up
28
- * ~/.howtofix/agent/agents/*.md the CLI's own user scope
29
- *
30
- * `parseFrontmatter` is the SDK's YAML parser, which means quoted values,
31
- * arrays, comments and anything else YAML allows are read the same way the
32
- * harness reads them. The user-scope directory is the one the run store also
33
- * uses (`AGENTS_STORE_DIR`); the two do not collide because the store reads only
34
- * `*.json` and this reads only `*.md`.
35
- *
36
- * A definition replaces a built-in of the same name. That is the point of having
37
- * them: `scout`, `architect`, `task` and `reviewer` are a starting set, not a
38
- * fixed menu, and an agent whose prompt is wrong for a project should be fixable
39
- * without patching this package.
40
- */
41
- /** The effort levels a definition may declare. Ours, not the harness's. */
42
- const EFFORTS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
43
- /** Frontmatter keys this loader reads; anything else is reported and ignored. */
44
- const KEYS = ["name", "description", "tools", "model", "effort"];
45
- /**
46
- * Tools a definition may name.
47
- *
48
- * Not a permission list — the permission extension still decides what a child
49
- * may actually do — but a spelling list, so a typo is reported at load instead of
50
- * silently resolving to nothing at call time. `mcp__<server>__<tool>` names are
51
- * accepted as written because they cannot be known in advance.
52
- */
53
- export const KNOWN_AGENT_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls", "bash_output", "kill_shell", "todo", "context", "goal", "task", "workflow", "web_search", "web_fetch", "lsp", "quality", "claim", "repo_map"];
54
- /**
55
- * What a definition gets when it names no tools: the same set the built-in `task`
56
- * agent has.
57
- *
58
- * The harness leaves an absent `tools` meaning "no restriction". Here the field
59
- * is the child's allowlist, so absent has to mean something concrete, and a
60
- * working set is the useful reading: the permission extension already refuses
61
- * anything above the parent's approval mode inside a child, and a spawned agent
62
- * has no interactive UI, so the gate fails closed. Narrowing is what `tools` is
63
- * for.
64
- */
65
- export const DEFAULT_AGENT_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls", "context", "todo", "web_search", "web_fetch", "lsp", "claim"];
66
- /** `a, b` and `[a, b]` both mean two tools; anything else means none declared. */
67
- function asToolList(value) {
68
- const raw = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : [];
69
- const tools = raw
70
- .map((item) => String(item)
71
- .trim()
72
- .replace(/^["']|["']$/g, ""))
73
- .filter(Boolean);
74
- return tools.length > 0 ? tools : undefined;
75
- }
76
- function isDirectory(path) {
77
- try {
78
- return statSync(path).isDirectory();
79
- }
80
- catch {
81
- return false;
82
- }
83
- }
84
- /**
85
- * The nearest project agent directory, walking up from `cwd`.
86
- *
87
- * Walking up is the harness's rule and the useful one: a definition written for a
88
- * monorepo root applies to every package under it.
89
- */
90
- export function findProjectAgentDir(cwd) {
91
- let current = cwd;
92
- for (;;) {
93
- // Beside project skills and presets: one project directory, .howtofix, so a
94
- // repository does not end up with two dot-directories holding the same kind
95
- // of file.
96
- const candidate = join(current, ".howtofix", "agents");
97
- if (isDirectory(candidate))
98
- return candidate;
99
- const parent = dirname(current);
100
- if (parent === current)
101
- return undefined;
102
- current = parent;
103
- }
104
- }
105
- /**
106
- * The directories definitions are read from, in load order.
107
- *
108
- * User first, project last, and a later definition replaces an earlier one — so a
109
- * project agent wins a name collision, the precedence the harness's own subagent
110
- * discovery uses.
111
- */
112
- export function subagentDirs(cwd) {
113
- // One user location, the one this CLI owns. The harness default, ~/.hxf/agent,
114
- // is not read: two folders holding the same kind of file is how a definition
115
- // ends up in the place nothing looks at.
116
- const dirs = [{ scope: "user", dir: join(getAgentDir(), "agents") }];
117
- const project = findProjectAgentDir(cwd);
118
- if (project)
119
- dirs.push({ scope: "project", dir: project });
120
- return dirs;
121
- }
122
- /**
123
- * Parse one definition file. Pure apart from the SDK's YAML parser, so the rules
124
- * can be tested without touching a filesystem.
125
- */
126
- export function parseAgentDef(text, file) {
127
- const notes = [];
128
- const problems = [];
129
- const source = text.replace(/^\uFEFF/, "");
130
- if (!/^---\r?\n/.test(source)) {
131
- return { notes, problems: [`${file}: no frontmatter — the file must start with --- and end the block with ---`] };
132
- }
133
- let frontmatter = {};
134
- let body = "";
135
- try {
136
- const parsed = parseFrontmatter(source);
137
- frontmatter = parsed.frontmatter ?? {};
138
- body = (parsed.body ?? "").trim();
139
- }
140
- catch (error) {
141
- return { notes, problems: [`${file}: the frontmatter is not readable YAML — ${error instanceof Error ? error.message : String(error)}`] };
142
- }
143
- for (const key of Object.keys(frontmatter)) {
144
- if (!KEYS.includes(key))
145
- notes.push(`${file}: ignoring the unknown frontmatter key '${key}'`);
146
- }
147
- // A file's own name is the natural default, so `reviewer.md` needs no `name:`.
148
- const stem = (file.split(/[\\/]/).pop() ?? file).replace(/\.md$/i, "");
149
- const rawName = frontmatter.name;
150
- const name = (typeof rawName === "string" && rawName.trim() ? rawName : stem).trim();
151
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
152
- problems.push(`${file}: '${name}' is not a usable agent name — letters, digits, dot, dash and underscore only`);
153
- }
154
- const rawDescription = frontmatter.description;
155
- const description = typeof rawDescription === "string" ? rawDescription.trim() : "";
156
- if (!description)
157
- problems.push(`${file}: a description is required — it is how the model decides to use this agent`);
158
- if (!body)
159
- problems.push(`${file}: no prompt body — the text below the frontmatter is the agent's standing instruction`);
160
- const rawEffort = frontmatter.effort;
161
- const effort = typeof rawEffort === "string" ? rawEffort.trim().toLowerCase() : "";
162
- if (effort && !EFFORTS.includes(effort)) {
163
- problems.push(`${file}: effort '${effort}' is not one of ${EFFORTS.join(", ")}`);
164
- }
165
- const declared = asToolList(frontmatter.tools);
166
- const tools = declared ?? [...DEFAULT_AGENT_TOOLS];
167
- if (declared) {
168
- const unknown = declared.filter((tool) => !KNOWN_AGENT_TOOLS.includes(tool) && !tool.startsWith("mcp__"));
169
- if (unknown.length > 0) {
170
- notes.push(`${file}: unknown tool${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")} — kept, but nothing registers them yet`);
171
- }
172
- }
173
- const rawModel = frontmatter.model;
174
- const model = typeof rawModel === "string" && rawModel.trim() ? rawModel.trim() : undefined;
175
- if (problems.length > 0)
176
- return { notes, problems };
177
- return {
178
- def: { name, description, tools, prompt: body, effort: (effort || "medium"), model, source: file },
179
- notes,
180
- problems,
181
- };
182
- }
183
- /**
184
- * Load every definition into `into`.
185
- *
186
- * Re-running cannot shuffle which definition a session runs with: the same files
187
- * produce the same map, and a definition only ever replaces one that loaded
188
- * before it.
189
- */
190
- export function loadAgentDefs(cwd, into) {
191
- const loaded = [];
192
- const notes = [];
193
- const problems = [];
194
- const scopes = [];
195
- for (const { scope, dir } of subagentDirs(cwd)) {
196
- let entries;
197
- try {
198
- entries = readdirSync(dir);
199
- }
200
- catch {
201
- // No directory is the normal case, not a problem to report.
202
- continue;
203
- }
204
- for (const entry of entries.filter((name) => name.toLowerCase().endsWith(".md")).sort()) {
205
- const file = join(dir, entry);
206
- let text;
207
- try {
208
- text = readFileSync(file, "utf8");
209
- }
210
- catch (error) {
211
- problems.push(`${file}: ${error instanceof Error ? error.message : String(error)}`);
212
- continue;
213
- }
214
- const parsed = parseAgentDef(text, file);
215
- notes.push(...parsed.notes);
216
- problems.push(...parsed.problems);
217
- const def = parsed.def;
218
- if (!def)
219
- continue;
220
- if (into.has(def.name)) {
221
- notes.push(`${file}: replaces ${BUILTIN_AGENT_NAMES.has(def.name) ? "the built-in" : "an earlier"} '${def.name}'`);
222
- }
223
- into.set(def.name, def);
224
- loaded.push(def);
225
- if (!scopes.includes(scope))
226
- scopes.push(scope);
227
- }
228
- }
229
- if (loaded.length > 0) {
230
- // The effective set, not every file read: a definition that a later one
231
- // replaced is not what a session will run with, and naming it twice would
232
- // read as two agents.
233
- const effective = loaded.filter((def) => into.get(def.name) === def);
234
- notes.unshift(`loaded ${effective.length} subagent definition${effective.length === 1 ? "" : "s"} (${scopes.join(" and ")}): ${effective.map((def) => def.name).join(", ")}`);
235
- }
236
- return { loaded, notes, problems };
237
- }