@harnessforce/claude-code-plugin 0.0.0-stage → 0.2.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/.claude-plugin/plugin.json +11 -0
- package/.mcp.json +8 -0
- package/LICENSE +201 -0
- package/README.md +143 -2
- package/commands/help.md +31 -0
- package/commands/import.md +18 -0
- package/commands/run.md +25 -0
- package/commands/setup.md +65 -0
- package/commands/status.md +26 -0
- package/commands/tune.md +39 -0
- package/hooks/hooks.json +127 -0
- package/package.json +26 -3
- package/scripts/harnessforce-hook-main.cjs +1895 -0
- package/scripts/harnessforce-hook.cjs +23 -0
- package/skills/propose-improvements/SKILL.md +105 -0
- package/skills/propose-improvements/scripts/summarize.mjs +474 -0
- package/skills/record-run/SKILL.md +29 -0
- package/skills/using-harnessforce/SKILL.md +54 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// hookのentry。18未満のNode.jsでも構文解析できるよう、ES5とCommonJSだけで書く(correlation.md「実行環境」)。
|
|
2
|
+
// 本体は組み込みのfetchを使うため、Node.js 18以上でだけ読み込む。
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
var major = Number(process.versions.node.split(".")[0]);
|
|
6
|
+
|
|
7
|
+
if (major >= 18) {
|
|
8
|
+
require("./harnessforce-hook-main.cjs");
|
|
9
|
+
} else {
|
|
10
|
+
process.stderr.write(
|
|
11
|
+
"harnessforce: session registration skipped (unsupported node)\n"
|
|
12
|
+
);
|
|
13
|
+
// 利用者に届く表示。文言はagent-coreのUNSUPPORTED_NODE_WARNINGと同じ値で、testで一致を確かめる。
|
|
14
|
+
// 繰り返し表示しないよう、session開始のhookだけが出す。
|
|
15
|
+
if (process.argv[2] === "session-start") {
|
|
16
|
+
process.stdout.write(
|
|
17
|
+
// biome-ignore lint/style/useTemplate: 古いNode.jsがES5として解析できる形を保つ。templateリテラルはES2015である。
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
systemMessage: "Harnessforce hooks require Node.js 18 or later",
|
|
20
|
+
}) + "\n"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: propose-improvements
|
|
3
|
+
description: From the analysis results of /harnessforce:tune, make suggested improvements for human interventions, repeatable loops, MCP servers, and usage (context compaction, cache reuse, model choice), record them with harnessforce tune record, and then show them. Use it only for targets that /harnessforce:tune marks as "a suggestion can be made". Does not modify files.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Make improvement suggestions from the analysis results
|
|
7
|
+
|
|
8
|
+
For each target marked "a suggestion can be made" in the "Targets for suggestions" printed by `summarize.mjs` of `/harnessforce:tune`, make a suggestion. Make each suggestion in a form the user can apply as it is, and record it before showing it.
|
|
9
|
+
|
|
10
|
+
## Do not modify anything
|
|
11
|
+
|
|
12
|
+
- Do not modify the user's files, settings, repository, or managed settings. Do not use the Edit or Write tools, `git apply`, `git commit`, creating a branch, or `gh pr create`. The diff and the Pull Request draft are only shown.
|
|
13
|
+
- The only thing written is the record that `harnessforce tune record` writes to `~/.harnessforce/tune/`. Do not create temporary files either.
|
|
14
|
+
|
|
15
|
+
## Conditions for making a suggestion
|
|
16
|
+
|
|
17
|
+
The result of `summarize.mjs` is authoritative. Make no suggestion for a target marked "Not enough data" or "not measured, so no suggestion is made". The conditions are as follows.
|
|
18
|
+
|
|
19
|
+
| Target | Minimum condition for a suggestion |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| All | The analyzed range has at least 10 sessions |
|
|
22
|
+
| Human interventions | That `intervention.kind` appears in at least 3 sessions and at least 10 times in total |
|
|
23
|
+
| Repeatable loops | That `loop.kind` appears at least 3 times in total |
|
|
24
|
+
| MCP servers | At least 10 sessions have `configured` |
|
|
25
|
+
| `frequent_compaction` and `low_cache_reuse` of usage | At least 3 sessions match that `usage.kind` |
|
|
26
|
+
| `model_choice` of usage | At least 10 sessions have `models`, and in the total of `models` over the analyzed range, one `message.model` value accounts for at least 90% of the total `output_tokens` |
|
|
27
|
+
|
|
28
|
+
- Do not use a value that was not measured (`not_measured`) as evidence. Do not treat "not measured" as 0.
|
|
29
|
+
- The change type of a usage suggestion is limited to `claude_md`, `rule`, `skill`, `agent`, and `command` (for example, split up a bloated CLAUDE.md, or move exploration to a subagent and specify that agent's `model`). Do not use another change type such as the `model` in settings. `harnessforce tune record` rejects other change types. A change to `claude_md` is not detected when applied, so it does not appear in the before and after of the previous suggestion either.
|
|
30
|
+
- Usage values (`usage`) stay on this device and are not sent to Harnessforce. You may use them as evidence for a suggestion.
|
|
31
|
+
- Do not make a suggestion about the harness configuration itself (differences in results between skills, rules, agents, commands, hooks, MCP servers, and model versions).
|
|
32
|
+
- If you investigate and find no change the user can apply as it is, make no suggestion for that target and say why in one line.
|
|
33
|
+
|
|
34
|
+
## Gather evidence
|
|
35
|
+
|
|
36
|
+
- Read only the parts you need of the record at the `transcript_path` of the target's evidence sessions. For human interventions, read the prompts of that kind and the agent response just before them. For loops, read the steps of one cycle (fetching the Issue, fixing, testing, pushing, creating the Pull Request, and so on) and the points where a person intervened. For MCP servers, read the calls and failures. For usage, read the instructions and the course of events before compaction for `frequent_compaction`, the responses before and after where the cache was not reused for `low_cache_reuse`, and the exploration and routine work done by the main agent for `model_choice`.
|
|
37
|
+
- When you make a suggestion for the same target as a suggestion that "Before and after the previous suggestion" shows as "Did not move in the improving direction", cite that before and after as evidence and do not repeat the same diff as last time.
|
|
38
|
+
- Read the current definition of the target (settings, skill, rule, agent, command, hook, `.mcp.json`, `mcpServers` in `~/.claude.json`, CLAUDE.md) and make the diff against the current content.
|
|
39
|
+
|
|
40
|
+
## Format
|
|
41
|
+
|
|
42
|
+
Give each suggestion the following.
|
|
43
|
+
|
|
44
|
+
| Item | Content |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| Target category | One of `intervention.kind=<kind>`, `loop.kind=<kind>`, `mcp_server=<identifier>`, `usage.kind=<kind>`. The `<kind>` of `usage.kind` is one of `frequent_compaction`, `low_cache_reuse`, `model_choice` |
|
|
47
|
+
| Evidence | The period, the counts, the times of the matching sessions, and excerpts of the records. Excerpts are shown on this device only |
|
|
48
|
+
| Change type | One of `permissions`, `hook`, `skill`, `rule`, `agent`, `command`, `loop_prompt`, `mcp_config`, `claude_md` |
|
|
49
|
+
| Apply to | The user scope, the repository scope, or managed settings, and the file path |
|
|
50
|
+
| Change | A unified diff that can be applied as it is. For the repository scope, a Pull Request draft made of the branch name, the title, the body, and the diff. For managed settings, the diff that an Owner or Admin pastes |
|
|
51
|
+
| Expected effect | Which category value decreases, and which existing metric (CI failure rate, rework rate, cost per completed Issue) changes |
|
|
52
|
+
| How to measure | The ComponentVersion that changes when applied (kind and identifier), and the metric used for comparison |
|
|
53
|
+
|
|
54
|
+
The change type and the component that changes when applied correspond as follows.
|
|
55
|
+
|
|
56
|
+
| Change type | Component `kind` | Component identifier (`id`) | Expected content |
|
|
57
|
+
| --- | --- | --- | --- |
|
|
58
|
+
| `permissions` | `permissions` | `permissions` | The whole `permissions` value of the settings after applying (`value`) |
|
|
59
|
+
| `hook` | `hook` | The event name of the hook | The value of that event under `hooks` in the settings after applying (`value`) |
|
|
60
|
+
| `skill` | `skill` | The directory name of the skill | The whole content of `SKILL.md` after applying (`content`) |
|
|
61
|
+
| `rule` | `rule` | `CLAUDE.md` or `rules/<path>` | The whole content of the file after applying (`content`) |
|
|
62
|
+
| `agent` | `agent` | The path from `agents/` without `.md`, with `/` replaced by `:` | The whole content of the file after applying (`content`) |
|
|
63
|
+
| `command` | `command` | The path from `commands/` without `.md`, with `/` replaced by `:` | The whole content of the file after applying (`content`) |
|
|
64
|
+
| `loop_prompt` | `command` or `skill`, following the format of the file written | Same as `command` or `skill` above | The whole content of the file after applying (`content`) |
|
|
65
|
+
| `mcp_config` | `mcp_server` (a diff that deletes the server has no component) | The server name | The definition of the server after applying (`value`). For deletion, the whole content of the file after applying (`content`) |
|
|
66
|
+
| `claude_md` | None | None | The whole content of CLAUDE.md after applying (`content`) |
|
|
67
|
+
|
|
68
|
+
The component `source` is the scope it applies to (`user`, `repository`, `local`, `managed`). A server in `.mcp.json` is `repository`, the top-level `mcpServers` of `~/.claude.json` is `user`, and the one under its `projects` is `local`.
|
|
69
|
+
|
|
70
|
+
## Suggestions to reduce permissions
|
|
71
|
+
|
|
72
|
+
- A suggestion to reduce answers to permission prompts takes the tools that asked for confirmation and the breakdown of their permissions as evidence. Allow only the smallest range of tools and commands that appeared in the evidence (for example `Bash(pnpm test:*)`, `Bash(git status)`, `mcp__github__get_issue`).
|
|
73
|
+
- Do not suggest a rule that allows all commands. Do not include in any suggestion a permission for a whole tool or all commands such as `Bash`, `Bash(*)`, `Bash(:*)`, or `*`, nor `"defaultMode": "bypassPermissions"` or `--dangerously-skip-permissions`.
|
|
74
|
+
|
|
75
|
+
## Suggestions for loops
|
|
76
|
+
|
|
77
|
+
- A suggestion for a repeatable loop is a prompt in the form of a command or a skill (`loop_prompt`), and it must include stopping conditions. The stopping conditions are both the maximum number of repetitions and the points where a person checks (for example before creating a Pull Request, or when the same failure happens twice in a row).
|
|
78
|
+
- In the prompt, replace each point where a person intervened in the evidence sessions with either a stopping condition or a step the agent does by itself.
|
|
79
|
+
|
|
80
|
+
## Record, then show
|
|
81
|
+
|
|
82
|
+
Before showing a suggestion, run `harnessforce tune record` for each suggestion and pass one UTF-8 JSON object on stdin. Pass the input with a heredoc and do not write it to a file. Use a delimiter word that does not appear in the input.
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
harnessforce tune record <<'HF_TUNE_RECORD_7f3c'
|
|
86
|
+
{"category": "...", "change_type": "...", "scope": "...", "path": "...", "evidence_session_ids": ["..."], "body": "..."}
|
|
87
|
+
HF_TUNE_RECORD_7f3c
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
| Item | Required | Content |
|
|
91
|
+
| --- | --- | --- |
|
|
92
|
+
| `category` | yes | The target category |
|
|
93
|
+
| `change_type` | yes | The change type |
|
|
94
|
+
| `scope` | yes | One of `user`, `repository`, `local`, `managed` |
|
|
95
|
+
| `path` | yes | The absolute path of the file the change writes |
|
|
96
|
+
| `project_root` | required when `scope` is `repository` or `local` | The absolute path of the root of the repository it applies to. Do not pass it otherwise |
|
|
97
|
+
| `component` | required for a change that has a component in the table above | `kind`, `source`, `id` |
|
|
98
|
+
| `content` | required when the `kind` of `component` is `rule`, `skill`, `agent`, or `command`, and when there is no component | The whole content of the file at `path` after applying |
|
|
99
|
+
| `value` | required when the `kind` of `component` is `hook`, `permissions`, or `mcp_server` | The settings value (JSON) of that component after applying |
|
|
100
|
+
| `evidence_session_ids` | yes | The session IDs of the evidence sessions (at least 1). Use only those shown in the evidence sessions |
|
|
101
|
+
| `body` | yes | The body of the suggestion (markdown): the evidence, the change, the expected effect, and how to measure |
|
|
102
|
+
|
|
103
|
+
- Show only the suggestions for which `harnessforce tune record` ended with exit code 0. Do not show a suggestion that ended with a non-zero code, and tell the user the message on stderr.
|
|
104
|
+
- Record the same suggestion with the same input. With the same `category`, `change_type`, `path`, and content after applying, it counts as one no matter how many times it is recorded.
|
|
105
|
+
- When showing, present the target category, the evidence, the change type, where to apply, the change, the expected effect, and how to measure, in this order.
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// `/harnessforce:tune`が使う。`harnessforce tune --json`を実行し、分析結果と、提案を作る条件の判定を表示する。
|
|
2
|
+
// 判定はimprovement-loop.md「作る条件」に従う。harnessforceの文言(stderr)と終了コードはそのまま渡す。
|
|
3
|
+
// Node.js 18で依存なしに動かすため、plainなESMで書く。
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const USAGE = "Usage: /harnessforce:tune [--all] [--no-send]";
|
|
7
|
+
const HF_MISSING =
|
|
8
|
+
"`harnessforce` was not found. Install the CLI with `/harnessforce:setup`";
|
|
9
|
+
const ALLOWED_FLAGS = new Set(["--all", "--no-send"]);
|
|
10
|
+
|
|
11
|
+
const MINIMUM_SESSIONS = 10;
|
|
12
|
+
const INTERVENTION_MIN_SESSIONS = 3;
|
|
13
|
+
const INTERVENTION_MIN_COUNT = 10;
|
|
14
|
+
const LOOP_MIN_OCCURRENCES = 3;
|
|
15
|
+
const MCP_MIN_CONFIGURED_SESSIONS = 10;
|
|
16
|
+
const MAX_EVIDENCE = 20;
|
|
17
|
+
const USAGE_MIN_SESSIONS = 3;
|
|
18
|
+
const MODEL_CHOICE_MIN_SESSIONS = 10;
|
|
19
|
+
const MODEL_CHOICE_MIN_SHARE = 0.9;
|
|
20
|
+
// 使い方のうち、sessionごとに判定する種類。model_choiceは分析した範囲の合計で判定する。
|
|
21
|
+
const SESSION_USAGE_KINDS = ["frequent_compaction", "low_cache_reuse"];
|
|
22
|
+
// improvement-loop.md「前回の提案の前後」の「良くなった向き」。model_choiceは向きを持たない。
|
|
23
|
+
const IMPROVES_BY = {
|
|
24
|
+
"intervention.kind": "decrease",
|
|
25
|
+
"loop.kind": "decrease",
|
|
26
|
+
mcp_server: "decrease",
|
|
27
|
+
"usage.kind=frequent_compaction": "decrease",
|
|
28
|
+
"usage.kind=low_cache_reuse": "increase",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const INTERVENTION_KINDS = [
|
|
32
|
+
"approval",
|
|
33
|
+
"continue",
|
|
34
|
+
"ci_relay",
|
|
35
|
+
"review_relay",
|
|
36
|
+
"answer",
|
|
37
|
+
"other",
|
|
38
|
+
];
|
|
39
|
+
const LOOP_KINDS = [
|
|
40
|
+
"issue_to_pr",
|
|
41
|
+
"ci_fix",
|
|
42
|
+
"review_response",
|
|
43
|
+
"test_fix",
|
|
44
|
+
"lint_fix",
|
|
45
|
+
"dependency_update",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const median = (values) => {
|
|
49
|
+
if (values.length === 0) return null;
|
|
50
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
51
|
+
const middle = Math.floor(sorted.length / 2);
|
|
52
|
+
return sorted.length % 2 === 1
|
|
53
|
+
? sorted[middle]
|
|
54
|
+
: (sorted[middle - 1] + sorted[middle]) / 2;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const secondsText = (value) => (value === null ? "—" : `${value.toFixed(1)}s`);
|
|
58
|
+
|
|
59
|
+
const newestFirst = (a, b) =>
|
|
60
|
+
a.session.started_at < b.session.started_at
|
|
61
|
+
? 1
|
|
62
|
+
: a.session.started_at > b.session.started_at
|
|
63
|
+
? -1
|
|
64
|
+
: a.session.session_id.localeCompare(b.session.session_id);
|
|
65
|
+
|
|
66
|
+
// 計測したsessionだけを合計する。1つも無ければundefined(未計測)とし、0と区別する。
|
|
67
|
+
function measure(sessions, pick, countField, medianField) {
|
|
68
|
+
const measured = sessions
|
|
69
|
+
.map((session) => ({ session, value: pick(session.report) }))
|
|
70
|
+
.filter(({ value }) => value?.measurement === "measured");
|
|
71
|
+
if (measured.length === 0) return undefined;
|
|
72
|
+
const hits = measured.filter(({ value }) => value[countField] > 0);
|
|
73
|
+
return {
|
|
74
|
+
total: measured.reduce((sum, { value }) => sum + value[countField], 0),
|
|
75
|
+
median: median(
|
|
76
|
+
measured
|
|
77
|
+
.map(({ value }) => value[medianField])
|
|
78
|
+
.filter((m) => typeof m === "number"),
|
|
79
|
+
),
|
|
80
|
+
hits: hits.map(({ session, value }) => ({
|
|
81
|
+
session,
|
|
82
|
+
count: value[countField],
|
|
83
|
+
})),
|
|
84
|
+
measured,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function mcpServers(sessions) {
|
|
89
|
+
const byServer = new Map();
|
|
90
|
+
for (const session of sessions)
|
|
91
|
+
for (const element of session.report.mcp_servers ?? []) {
|
|
92
|
+
const entries = byServer.get(element.server) ?? [];
|
|
93
|
+
entries.push({ session, element });
|
|
94
|
+
byServer.set(element.server, entries);
|
|
95
|
+
}
|
|
96
|
+
return [...byServer.entries()]
|
|
97
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
98
|
+
.map(([server, entries]) => {
|
|
99
|
+
const configured = entries.filter(({ element }) => element.configured);
|
|
100
|
+
const sum = (field, measurement) => {
|
|
101
|
+
const measured = entries.filter(
|
|
102
|
+
({ element }) => element[measurement] === "measured",
|
|
103
|
+
);
|
|
104
|
+
return measured.length === 0
|
|
105
|
+
? undefined
|
|
106
|
+
: measured.reduce((total, { element }) => total + element[field], 0);
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
server,
|
|
110
|
+
configured,
|
|
111
|
+
calls: sum("calls", "measurement"),
|
|
112
|
+
failures: sum("failures", "failures_measurement"),
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function evidenceLines(hits, unit) {
|
|
118
|
+
const sorted = [...hits].sort(newestFirst);
|
|
119
|
+
const lines = [" Evidence sessions (newest first):"];
|
|
120
|
+
for (const { session, count } of sorted.slice(0, MAX_EVIDENCE))
|
|
121
|
+
lines.push(
|
|
122
|
+
` ${session.session_id} ${session.started_at} ${session.repository ?? "-"} sendable=${session.sendable} ${count}${unit} ${session.transcript_path}`,
|
|
123
|
+
);
|
|
124
|
+
if (sorted.length > MAX_EVIDENCE)
|
|
125
|
+
lines.push(` and ${sorted.length - MAX_EVIDENCE} more`);
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const missing = (parts) =>
|
|
130
|
+
parts
|
|
131
|
+
.filter(([amount]) => amount > 0)
|
|
132
|
+
.map(([amount, unit]) => `${amount}${unit}`)
|
|
133
|
+
.join(", ");
|
|
134
|
+
|
|
135
|
+
const percent = (share) => `${(share * 100).toFixed(1)}%`;
|
|
136
|
+
|
|
137
|
+
// `models`(本体)と`subagent_models`の合計を分母にした、modelごとのoutput_tokensの割合。
|
|
138
|
+
// どちらかがnullのsessionは除く。分母が0ならnull。
|
|
139
|
+
function modelShares(sessions) {
|
|
140
|
+
const counted = sessions.filter(
|
|
141
|
+
(s) =>
|
|
142
|
+
s.usage && s.usage.models !== null && s.usage.subagent_models !== null,
|
|
143
|
+
);
|
|
144
|
+
const totals = (field) => {
|
|
145
|
+
const byModel = new Map();
|
|
146
|
+
for (const s of counted)
|
|
147
|
+
for (const m of s.usage[field])
|
|
148
|
+
byModel.set(m.model, (byModel.get(m.model) ?? 0) + m.output_tokens);
|
|
149
|
+
return byModel;
|
|
150
|
+
};
|
|
151
|
+
const main = totals("models");
|
|
152
|
+
const subagent = totals("subagent_models");
|
|
153
|
+
const denominator = [...main.values(), ...subagent.values()].reduce(
|
|
154
|
+
(total, v) => total + v,
|
|
155
|
+
0,
|
|
156
|
+
);
|
|
157
|
+
if (denominator === 0) return null;
|
|
158
|
+
const shares = (byModel) =>
|
|
159
|
+
[...byModel]
|
|
160
|
+
// harnessforce tune の表示(UTF-16 の code unit 順)と並びを揃える。
|
|
161
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
162
|
+
.map(([model, tokens]) => ({ model, share: tokens / denominator }));
|
|
163
|
+
return { main: shares(main), subagent: shares(subagent) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const sharesText = (shares) =>
|
|
167
|
+
shares === null
|
|
168
|
+
? "none"
|
|
169
|
+
: [
|
|
170
|
+
...shares.main.map((m) => `main ${m.model} ${percent(m.share)}`),
|
|
171
|
+
...shares.subagent.map(
|
|
172
|
+
(m) => `subagent ${m.model} ${percent(m.share)}`,
|
|
173
|
+
),
|
|
174
|
+
].join(", ");
|
|
175
|
+
|
|
176
|
+
// 「作る条件」の`model_choice`: 分析した範囲の`models`の合計で、output_tokensが最も多いmodelとその割合。
|
|
177
|
+
function topMainModel(sessions) {
|
|
178
|
+
const byModel = new Map();
|
|
179
|
+
for (const s of sessions)
|
|
180
|
+
for (const m of s.usage.models)
|
|
181
|
+
byModel.set(m.model, (byModel.get(m.model) ?? 0) + m.output_tokens);
|
|
182
|
+
const total = [...byModel.values()].reduce((sum, v) => sum + v, 0);
|
|
183
|
+
const [model, tokens] = [...byModel].sort(
|
|
184
|
+
([a, x], [b, y]) => y - x || a.localeCompare(b),
|
|
185
|
+
)[0] ?? [undefined, 0];
|
|
186
|
+
return model === undefined || total === 0
|
|
187
|
+
? undefined
|
|
188
|
+
: { model, tokens, share: tokens / total };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const valueText = (value) =>
|
|
192
|
+
value === null
|
|
193
|
+
? "no value"
|
|
194
|
+
: typeof value === "number"
|
|
195
|
+
? String(Number(value.toFixed(3)))
|
|
196
|
+
: sharesText(value);
|
|
197
|
+
|
|
198
|
+
function improvesBy(category) {
|
|
199
|
+
return IMPROVES_BY[category] ?? IMPROVES_BY[category.split("=")[0]];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// improvement-loop.md「前回の提案の前後」
|
|
203
|
+
function renderFollowups(followups) {
|
|
204
|
+
if (!Array.isArray(followups) || followups.length === 0) return [];
|
|
205
|
+
const lines = [
|
|
206
|
+
"Before and after the previous suggestion (Before/after difference (does not show causation). The starting point is the time the application was detected):",
|
|
207
|
+
];
|
|
208
|
+
for (const f of followups) {
|
|
209
|
+
lines.push(
|
|
210
|
+
`- ${f.category} (${f.change_type} ${f.path}) starting point ${f.origin}`,
|
|
211
|
+
` Before ${f.before.from} to ${f.before.to}: ${f.before.sessions} session(s), ${valueText(f.before.value)}`,
|
|
212
|
+
` After ${f.after.from} to ${f.after.to}: ${f.after.sessions} session(s), ${valueText(f.after.value)}`,
|
|
213
|
+
);
|
|
214
|
+
if (f.no_comparison_data) lines.push(" No comparison data");
|
|
215
|
+
if (f.before_outside_range)
|
|
216
|
+
lines.push(
|
|
217
|
+
" Part of the earlier period is outside the analysis range",
|
|
218
|
+
);
|
|
219
|
+
const direction = improvesBy(f.category);
|
|
220
|
+
const isImproved =
|
|
221
|
+
direction === "decrease"
|
|
222
|
+
? f.after.value < f.before.value
|
|
223
|
+
: f.after.value > f.before.value;
|
|
224
|
+
if (direction !== undefined && !f.no_comparison_data && !isImproved)
|
|
225
|
+
lines.push(
|
|
226
|
+
" Did not move in the improving direction. Use this as the basis when making a suggestion for the same target",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
lines.push("");
|
|
230
|
+
return lines;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function usageTargets(sessions) {
|
|
234
|
+
const lines = [];
|
|
235
|
+
// usage を出力しない古い harnessforce の出力では、使い方を未計測として扱い提案を作らない。
|
|
236
|
+
const withUsage = sessions.filter((s) => s.usage);
|
|
237
|
+
for (const kind of SESSION_USAGE_KINDS) {
|
|
238
|
+
const category = `usage.kind=${kind}`;
|
|
239
|
+
if (withUsage.length === 0) {
|
|
240
|
+
lines.push(`- ${category}: not measured, so no suggestion is made`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const hits = withUsage.filter((s) => s.usage.kinds.includes(kind));
|
|
244
|
+
if (hits.length < USAGE_MIN_SESSIONS) {
|
|
245
|
+
lines.push(
|
|
246
|
+
`- ${category}: Not enough data: needs at least ${USAGE_MIN_SESSIONS} matching sessions (${USAGE_MIN_SESSIONS - hits.length} more session(s) needed)`,
|
|
247
|
+
);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
lines.push(
|
|
251
|
+
`- ${category}: a suggestion can be made (${hits.length} session(s))`,
|
|
252
|
+
...evidenceLines(
|
|
253
|
+
hits.map((session) => ({
|
|
254
|
+
session,
|
|
255
|
+
count:
|
|
256
|
+
kind === "frequent_compaction"
|
|
257
|
+
? session.usage.compactions_auto
|
|
258
|
+
: Number(session.usage.cache_reuse_ratio.toFixed(3)),
|
|
259
|
+
})),
|
|
260
|
+
kind === "frequent_compaction"
|
|
261
|
+
? " auto-compaction(s)"
|
|
262
|
+
: " (cache reuse ratio)",
|
|
263
|
+
),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const category = "usage.kind=model_choice";
|
|
267
|
+
const withModels = withUsage.filter(
|
|
268
|
+
(s) => Array.isArray(s.usage.models) && s.usage.models.length > 0,
|
|
269
|
+
);
|
|
270
|
+
if (withUsage.length === 0) {
|
|
271
|
+
lines.push(`- ${category}: not measured, so no suggestion is made`);
|
|
272
|
+
return lines;
|
|
273
|
+
}
|
|
274
|
+
if (withModels.length < MODEL_CHOICE_MIN_SESSIONS) {
|
|
275
|
+
lines.push(
|
|
276
|
+
`- ${category}: Not enough data: needs at least ${MODEL_CHOICE_MIN_SESSIONS} sessions with models (${MODEL_CHOICE_MIN_SESSIONS - withModels.length} more session(s) needed)`,
|
|
277
|
+
);
|
|
278
|
+
return lines;
|
|
279
|
+
}
|
|
280
|
+
const top = topMainModel(withModels);
|
|
281
|
+
if (top === undefined || top.share < MODEL_CHOICE_MIN_SHARE) {
|
|
282
|
+
lines.push(
|
|
283
|
+
`- ${category}: not suggested: no single model accounts for ${MODEL_CHOICE_MIN_SHARE * 100}% or more of the main output_tokens${top ? ` (largest: ${top.model} ${percent(top.share)})` : ""}`,
|
|
284
|
+
);
|
|
285
|
+
return lines;
|
|
286
|
+
}
|
|
287
|
+
lines.push(
|
|
288
|
+
`- ${category}: a suggestion can be made (${withModels.length} session(s), ${top.model} ${percent(top.share)})`,
|
|
289
|
+
...evidenceLines(
|
|
290
|
+
withModels.map((session) => ({
|
|
291
|
+
session,
|
|
292
|
+
count:
|
|
293
|
+
session.usage.models.find((m) => m.model === top.model)
|
|
294
|
+
?.output_tokens ?? 0,
|
|
295
|
+
})),
|
|
296
|
+
` output_tokens (${top.model})`,
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
return lines;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function render(output) {
|
|
303
|
+
const { sessions } = output;
|
|
304
|
+
const lines = [
|
|
305
|
+
`Sessions analyzed: ${output.session_count} (analyzer ${output.analyzer_version}, parser ${output.parser_version})`,
|
|
306
|
+
"",
|
|
307
|
+
"Human interventions (count, sessions with interventions, median wait time):",
|
|
308
|
+
];
|
|
309
|
+
const interventions = INTERVENTION_KINDS.map((kind) => ({
|
|
310
|
+
kind,
|
|
311
|
+
result: measure(
|
|
312
|
+
sessions,
|
|
313
|
+
(r) => r.interventions?.[kind],
|
|
314
|
+
"count",
|
|
315
|
+
"wait_seconds_median",
|
|
316
|
+
),
|
|
317
|
+
}));
|
|
318
|
+
for (const { kind, result } of interventions)
|
|
319
|
+
lines.push(
|
|
320
|
+
result === undefined
|
|
321
|
+
? ` ${kind}: not measured`
|
|
322
|
+
: ` ${kind}: ${result.total} time(s), ${result.hits.length} session(s), ${secondsText(result.median)}`,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
lines.push(
|
|
326
|
+
"",
|
|
327
|
+
"Repeatable loops (count, interventions during them, median duration):",
|
|
328
|
+
);
|
|
329
|
+
const loops = LOOP_KINDS.map((kind) => ({
|
|
330
|
+
kind,
|
|
331
|
+
result: measure(
|
|
332
|
+
sessions,
|
|
333
|
+
(r) => r.loops?.[kind],
|
|
334
|
+
"occurrences",
|
|
335
|
+
"duration_seconds_median",
|
|
336
|
+
),
|
|
337
|
+
}));
|
|
338
|
+
for (const { kind, result } of loops) {
|
|
339
|
+
if (result === undefined) {
|
|
340
|
+
lines.push(` ${kind}: not measured`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const count = result.measured.reduce(
|
|
344
|
+
(sum, { value }) => sum + value.interventions,
|
|
345
|
+
0,
|
|
346
|
+
);
|
|
347
|
+
lines.push(
|
|
348
|
+
` ${kind}: ${result.total} time(s), ${count} intervention(s), ${secondsText(result.median)}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
lines.push("", "MCP servers (sessions where configured, calls, failures):");
|
|
353
|
+
const servers = mcpServers(sessions);
|
|
354
|
+
if (servers.length === 0) lines.push(" (no MCP servers in the records)");
|
|
355
|
+
for (const s of servers)
|
|
356
|
+
lines.push(
|
|
357
|
+
` ${s.server}: configured in ${s.configured.length} session(s), ${s.calls === undefined ? "calls: not measured" : `${s.calls} call(s)`}, ${s.failures === undefined ? "failures: not measured" : `${s.failures} failure(s)`}`,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const withUsage = sessions.filter((s) => s.usage);
|
|
361
|
+
lines.push("", "Usage (matching sessions):");
|
|
362
|
+
for (const kind of SESSION_USAGE_KINDS)
|
|
363
|
+
lines.push(
|
|
364
|
+
withUsage.length === 0
|
|
365
|
+
? ` ${kind}: not measured`
|
|
366
|
+
: ` ${kind}: ${withUsage.filter((s) => s.usage.kinds.includes(kind)).length} session(s)`,
|
|
367
|
+
);
|
|
368
|
+
lines.push(
|
|
369
|
+
` model (share of output_tokens): ${sharesText(modelShares(withUsage))}`,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
lines.push("", ...renderFollowups(output.followups));
|
|
373
|
+
if (!output.sufficient) {
|
|
374
|
+
lines.push(
|
|
375
|
+
`Not enough data, so no suggestions are made: ${output.session_count} session(s) analyzed. Suggestions need at least ${MINIMUM_SESSIONS} (${MINIMUM_SESSIONS - output.session_count} more needed)`,
|
|
376
|
+
);
|
|
377
|
+
return lines;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
lines.push("Targets for suggestions:");
|
|
381
|
+
for (const { kind, result } of interventions) {
|
|
382
|
+
const category = `intervention.kind=${kind}`;
|
|
383
|
+
if (result === undefined) {
|
|
384
|
+
lines.push(`- ${category}: not measured, so no suggestion is made`);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const lacking = missing([
|
|
388
|
+
[INTERVENTION_MIN_SESSIONS - result.hits.length, " session(s)"],
|
|
389
|
+
[INTERVENTION_MIN_COUNT - result.total, " time(s)"],
|
|
390
|
+
]);
|
|
391
|
+
if (lacking) {
|
|
392
|
+
lines.push(
|
|
393
|
+
`- ${category}: Not enough data: needs at least ${INTERVENTION_MIN_SESSIONS} sessions and at least ${INTERVENTION_MIN_COUNT} times in total (${lacking} more needed)`,
|
|
394
|
+
);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
lines.push(
|
|
398
|
+
`- ${category}: a suggestion can be made (${result.hits.length} session(s), ${result.total} time(s))`,
|
|
399
|
+
...evidenceLines(result.hits, " time(s)"),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
for (const { kind, result } of loops) {
|
|
403
|
+
const category = `loop.kind=${kind}`;
|
|
404
|
+
if (result === undefined) {
|
|
405
|
+
lines.push(`- ${category}: not measured, so no suggestion is made`);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (result.total < LOOP_MIN_OCCURRENCES) {
|
|
409
|
+
lines.push(
|
|
410
|
+
`- ${category}: Not enough data: needs at least ${LOOP_MIN_OCCURRENCES} times in total (${LOOP_MIN_OCCURRENCES - result.total} more needed)`,
|
|
411
|
+
);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
lines.push(
|
|
415
|
+
`- ${category}: a suggestion can be made (${result.total} time(s) in total)`,
|
|
416
|
+
...evidenceLines(result.hits, " time(s)"),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
// unlistedは構成に無いserverをまとめたものであり、設定されていたsessionを持たないため対象にしない。
|
|
420
|
+
for (const s of servers.filter((server) => server.server !== "unlisted")) {
|
|
421
|
+
const category = `mcp_server=${s.server}`;
|
|
422
|
+
if (s.calls === undefined && s.failures === undefined) {
|
|
423
|
+
lines.push(`- ${category}: not measured, so no suggestion is made`);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (s.configured.length < MCP_MIN_CONFIGURED_SESSIONS) {
|
|
427
|
+
lines.push(
|
|
428
|
+
`- ${category}: Not enough data: needs at least ${MCP_MIN_CONFIGURED_SESSIONS} sessions where it was configured (${MCP_MIN_CONFIGURED_SESSIONS - s.configured.length} more needed)`,
|
|
429
|
+
);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
lines.push(
|
|
433
|
+
`- ${category}: a suggestion can be made (configured in ${s.configured.length} session(s))`,
|
|
434
|
+
...evidenceLines(
|
|
435
|
+
s.configured.map(({ session, element }) => ({
|
|
436
|
+
session,
|
|
437
|
+
count: element.calls ?? 0,
|
|
438
|
+
})),
|
|
439
|
+
" call(s)",
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
lines.push(...usageTargets(sessions));
|
|
444
|
+
return lines;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function run(args) {
|
|
448
|
+
if (!args.every((arg) => ALLOWED_FLAGS.has(arg))) {
|
|
449
|
+
process.stderr.write(`${USAGE}\n`);
|
|
450
|
+
return 2;
|
|
451
|
+
}
|
|
452
|
+
// Windowsのnpmのharnessforceは.cmdであり、shellを通さないと起動できない。引数は上で許した値だけである。
|
|
453
|
+
const hf = spawnSync("harnessforce", ["tune", "--json", ...new Set(args)], {
|
|
454
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
455
|
+
encoding: "utf8",
|
|
456
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
457
|
+
shell: process.platform === "win32",
|
|
458
|
+
});
|
|
459
|
+
if (hf.error?.code === "ENOENT") {
|
|
460
|
+
process.stderr.write(`${HF_MISSING}\n`);
|
|
461
|
+
return 1;
|
|
462
|
+
}
|
|
463
|
+
const code = hf.status ?? 1;
|
|
464
|
+
let output;
|
|
465
|
+
try {
|
|
466
|
+
output = JSON.parse(hf.stdout);
|
|
467
|
+
} catch {
|
|
468
|
+
return code;
|
|
469
|
+
}
|
|
470
|
+
process.stdout.write(`${render(output).join("\n")}\n`);
|
|
471
|
+
return code;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
process.exitCode = run(process.argv.slice(2));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: record-run
|
|
3
|
+
description: For work on a Harnessforce Issue (an identifier such as ENG-42), use the tools of the Harnessforce MCP server to register this session as a Run and to close the Run when the work ends. Use it when you start work on an Issue and when you finish the work.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Record work on a Harnessforce Issue
|
|
7
|
+
|
|
8
|
+
Use the tools of the Harnessforce MCP server to link the work to the Issue. The first time you use a tool, you are asked to log in in the browser and choose a Workspace.
|
|
9
|
+
|
|
10
|
+
## At the start of the work
|
|
11
|
+
|
|
12
|
+
1. Pass the value of `harnessforce session_id: <session_id>` in the context as `session_id`, and register the Run with `start_run`. The Harnessforce plugin adds this line to the context at session start. If there are several, use the last one added. The input is the Issue identifier (`issue_identifier`), `agent` (`claude_code`), and `session_id`. If you know the repository, branch, and commit you are working on, pass them too. Keep the returned `run_id` until the end. If the line is missing from the context, follow "When the session ID is missing" below.
|
|
13
|
+
2. Check the Issue with `get_issue`.
|
|
14
|
+
|
|
15
|
+
## When the session ID changes
|
|
16
|
+
|
|
17
|
+
- If the value of the last `harnessforce session_id:` in the context differs from the value you passed to the previous `start_run` (for example after forking the conversation or running `/clear`), call `start_run` once more with the new value before continuing, and use the returned `run_id` for later `complete_run` calls.
|
|
18
|
+
|
|
19
|
+
## At the end of the work
|
|
20
|
+
|
|
21
|
+
- Call `complete_run` with the `run_id`.
|
|
22
|
+
|
|
23
|
+
## When the session ID is missing
|
|
24
|
+
|
|
25
|
+
- If the context has no `harnessforce session_id:` line, do not guess and pass a session ID, and do not call `start_run` or `complete_run`. `get_issue` does not depend on the Run, so continue with it. Tell the user once: "The Harnessforce hook is not running, so the Run cannot be recorded. Run `/harnessforce:setup`".
|
|
26
|
+
|
|
27
|
+
## What is not recorded
|
|
28
|
+
|
|
29
|
+
- Harnessforce does not record a Plan, a Decision, or a Definition of Done, and the Harnessforce tools do not create or edit Issues. Issues are GitHub Issues. Harnessforce shows the sessions, Pull Requests, and CI results linked to an Issue without any record from you.
|