@harnessforce/claude-code-plugin 0.2.0 → 0.3.1
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 +1 -1
- package/README.md +19 -2
- package/commands/setup.md +52 -20
- package/hooks/hooks.json +45 -1
- package/package.json +1 -1
- package/scripts/harnessforce-hook-main.cjs +181 -67
- package/skills/record-run/SKILL.md +14 -1
- package/skills/using-harnessforce/SKILL.md +4 -2
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ and does not bundle the CLI (`/harnessforce:setup` installs `@harnessforce/cli`
|
|
|
11
11
|
| Part | Purpose |
|
|
12
12
|
| --- | --- |
|
|
13
13
|
| `SessionStart` and `UserPromptSubmit` hooks | Register each session and its config snapshot with Harnessforce |
|
|
14
|
+
| `CwdChanged`, `Stop` and `PostToolUse` (`Write` and `Edit`) hooks | Record the repository and branch of the Git working trees a session works in, and send them at the end of a turn in which they changed |
|
|
14
15
|
| `PostToolUse`, `PostToolUseFailure`, `UserPromptExpansion`, `PermissionRequest`, `PostCompact` and `SessionEnd` hooks | Count the configured skills, commands, subagents and MCP servers a session uses, and send the session usage summary |
|
|
15
16
|
| MCP server `harnessforce` (`<connection URL>/mcp`, HTTP) | Tools to start a run, read an Issue, and complete a run |
|
|
16
17
|
| Skill `using-harnessforce` | What Harnessforce records and does not collect, which hook sends what, and when to use the other skills. The `SessionStart` hook injects its body into the context of every session (the build embeds the same text into the hook and the CLI) |
|
|
@@ -72,8 +73,8 @@ running. `harnessforce init` turns on automatic updates for the `harnessforce-ag
|
|
|
72
73
|
The `harnessforce` plugin registers each Claude Code session with Harnessforce so that runs can be
|
|
73
74
|
linked to issues. On `SessionStart` (`startup`, `clear`, `fork`) it sends the session ID and the git
|
|
74
75
|
repository, branch and commit of `cwd` to `POST <HARNESSFORCE_ENDPOINT>/v1/sessions`. On the first
|
|
75
|
-
prompt it sends the same registration again with the prompt ID.
|
|
76
|
-
contents are
|
|
76
|
+
prompt it sends the same registration again with the prompt ID. The hook never sends prompts,
|
|
77
|
+
responses or file contents; they are sent only through the OTel settings you choose.
|
|
77
78
|
|
|
78
79
|
- For an organization, set `HARNESSFORCE_ENDPOINT` (the ingest base URL, `https:`) and
|
|
79
80
|
`HARNESSFORCE_INGEST_KEY` (a Workspace ingest key) in
|
|
@@ -94,6 +95,22 @@ contents are never sent.
|
|
|
94
95
|
"The organization's ingest key has been revoked. Contact a Workspace administrator" (Workspace key) or
|
|
95
96
|
"Your ingest key has been revoked. Run `harnessforce init`" (user key)
|
|
96
97
|
|
|
98
|
+
## Work location hooks
|
|
99
|
+
|
|
100
|
+
A session often starts in the repository's main checkout and then works in a worktree or on a new
|
|
101
|
+
branch. To link such runs to their pull requests, the plugin records the repository and branch of
|
|
102
|
+
each Git working tree the session works in, at the time it observes it: the new working directory on
|
|
103
|
+
`CwdChanged`, the directory of the file on `PostToolUse` of `Write` and `Edit`, and `cwd` on `Stop`.
|
|
104
|
+
At the end of each turn (`Stop`), when these pairs differ from the pairs sent last, it sends them to
|
|
105
|
+
`POST <HARNESSFORCE_ENDPOINT>/v1/sessions/locations`.
|
|
106
|
+
|
|
107
|
+
- It sends only the agent, the session ID and up to 10 pairs of repository and branch. Paths,
|
|
108
|
+
commits, file names and the content of edits are never sent. The record lives in
|
|
109
|
+
`scratchpad_dir` (`locations-<session_id>.json`); without `scratchpad_dir` nothing is recorded.
|
|
110
|
+
- The hooks run asynchronously and never delay a turn. A failure writes
|
|
111
|
+
`harnessforce: session location failed (<reason>)` to stderr and is retried at the next turn; after a
|
|
112
|
+
`401` the session stops sending. `CwdChanged` writes nothing to stderr.
|
|
113
|
+
|
|
97
114
|
## Config snapshot
|
|
98
115
|
|
|
99
116
|
At the same `SessionStart`, the hook also sends a config snapshot to
|
package/commands/setup.md
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Harnessforce setup. Walks through checking Node.js, choosing whether to send
|
|
3
|
-
disable-model-invocation: true
|
|
2
|
+
description: Harnessforce setup. Walks through checking Node.js, choosing whether to send session content, installing the CLI, running harnessforce init, importing past sessions, showing the remaining workspace steps, and confirming the first event after the restart
|
|
4
3
|
---
|
|
5
4
|
|
|
6
5
|
# Harnessforce setup
|
|
7
6
|
|
|
8
|
-
Go through the steps below in order, interacting with the user.
|
|
7
|
+
Go through the steps below in order, interacting with the user. Run this setup when the user types `/harnessforce:setup`, or when the user asks to install, connect or set up Harnessforce. Do not run it when the user has not asked for it. Running it again is safe: every step converges to the same state.
|
|
8
|
+
|
|
9
|
+
## How setup ends
|
|
10
|
+
|
|
11
|
+
Setup ends only at one of the endings below, and the last line of the ending is the text shown here, as it is. Do not tell the user that setup has ended at any other point, and do not use words that say setup is finished ("complete", "done" and so on) except in the second ending of part 2.
|
|
12
|
+
|
|
13
|
+
- Node.js is missing or below 18 (step 1): the text in step 1.
|
|
14
|
+
- Stopped part way: "Harnessforce setup stopped at step {n} ({name}). Fix the cause shown above, then run `/harnessforce:setup` again. Steps already done are safe to repeat." Replace `{n}` and `{name}` with the number and the heading of the step that failed.
|
|
15
|
+
- End of part 1 (step 8): "Harnessforce setup, part 1 of 2, is done. Restart Claude Code, then run `/harnessforce:setup` again in the new session. Part 2 checks that the first event arrived."
|
|
16
|
+
- End of part 2 (step 9): "Harnessforce setup is complete."
|
|
17
|
+
|
|
18
|
+
When a command fails, show its output to the user. Whether setup then stops or goes on is written in each step. Errors that other plugins' hooks show do not affect setup: do not stop because of them and do not mention them.
|
|
9
19
|
|
|
10
20
|
## 1. Check Node.js
|
|
11
21
|
|
|
@@ -15,51 +25,73 @@ First run `node --version`. If the command is not found, or the version is below
|
|
|
15
25
|
|
|
16
26
|
## 2. Check whether this run is after the restart
|
|
17
27
|
|
|
18
|
-
If `harnessforce --version` succeeds and the environment variable `HARNESSFORCE_WORKSPACE_ID` of this session is not empty, this is a session restarted after `harnessforce init`. Skip steps 3 to
|
|
28
|
+
If `harnessforce --version` succeeds and the environment variable `HARNESSFORCE_WORKSPACE_ID` of this session is not empty, this is a session restarted after `harnessforce init`. Skip steps 3 to 8 and go to step 9.
|
|
19
29
|
|
|
20
30
|
## 3. Install the CLI
|
|
21
31
|
|
|
22
|
-
|
|
32
|
+
If `harnessforce --version` succeeds, do not install anything. Otherwise run the following, then confirm the install with `harnessforce --version`.
|
|
23
33
|
|
|
24
34
|
```sh
|
|
25
35
|
npm install -g @harnessforce/cli
|
|
26
36
|
```
|
|
27
37
|
|
|
28
|
-
|
|
38
|
+
If the install fails, show its output and stop at this step. When npm cannot write to its global install location (`EACCES`), also show the following before the last line.
|
|
29
39
|
|
|
30
|
-
|
|
40
|
+
> npm cannot write to its global install location. Use a Node.js installed with a version manager such as nvm, or change npm's prefix to your own directory, then try again.
|
|
41
|
+
|
|
42
|
+
## 4. Choose whether to send content
|
|
43
|
+
|
|
44
|
+
Before `harnessforce init`, present the option to send the content of the user's sessions, with "send" selected by default. The login is not done yet at this point, so neither the Workspace to connect nor what it stores is known. Do not change what you show depending on the Workspace; `harnessforce init` decides each category from the Workspace it connects, and writes `0` for a category the Workspace does not store.
|
|
31
45
|
|
|
32
46
|
Present the option together with the following.
|
|
33
47
|
|
|
34
|
-
- If selected, the
|
|
35
|
-
- Content is stored only when both this setting on the device and the Workspace
|
|
36
|
-
- If selected, run `harnessforce init --send-content`; if not, run `harnessforce init` (step 5). Write this setting to the `env` of the Claude Code user settings, and do not write it to the `env` of the project (`.claude/settings.json`) or local (`.claude/settings.local.json`) files, because the content
|
|
37
|
-
- To stop
|
|
48
|
+
- If selected, the prompts the user types, model responses, and tool input and output (including Bash commands and file paths) are delivered to this Workspace for the categories the Workspace stores. Secret patterns, email addresses, and phone numbers are replaced with `[REDACTED:kind]` before storing, and the content is deleted after the content retention period (30 days by default, at most 30 days). Secrets and personal information that do not match a pattern are not masked, and not everything can be detected. Who can read the stored content follows the Workspace setting (everyone in the Workspace by default); `harnessforce init` shows the setting of the Workspace it connects. The user can delete their own content from the account settings, and the sender, owners and admins can delete a session's content at any time.
|
|
49
|
+
- Content is stored only when both this setting on the device and the Workspace's storing of that category are in place. If the Workspace stops storing a category, later content is discarded on receipt.
|
|
50
|
+
- If selected, run `harnessforce init --send-content`; if not, run `harnessforce init` (step 5). Write this setting to the `env` of the Claude Code user settings, and do not write it to the `env` of the project (`.claude/settings.json`) or local (`.claude/settings.local.json`) files, because the content variables are ignored in the `env` of those two files. A restart is needed after `harnessforce init` for the setting to take effect.
|
|
51
|
+
- To stop afterwards, run `harnessforce init` again without `--send-content`. It rewrites the content variables to `0`.
|
|
38
52
|
|
|
39
53
|
Use the following option text as it is.
|
|
40
54
|
|
|
41
|
-
>
|
|
42
|
-
>
|
|
43
|
-
> Content is not sent (by default). You can enable it later.
|
|
55
|
+
> Send the content of your sessions (on by default). The prompts you type, model responses and tool input and output (including Bash commands and file paths) are delivered to this workspace and stored for the categories the workspace stores. Values that match known patterns for secrets and personal information are replaced with `[REDACTED:kind]`, but not everything can be detected. Content is deleted after the content retention period (30 days by default, at most 30 days). Who can read it follows the workspace setting (everyone in the workspace by default); `harnessforce init` shows the setting of the workspace you connect. You can delete your own content from your account settings, and the sender, owners and admins can delete a session's content at any time.
|
|
44
56
|
|
|
45
57
|
## 5. Connect to Harnessforce
|
|
46
58
|
|
|
47
|
-
Follow the choice in step 4: run `harnessforce init --send-content` if selected, or `harnessforce init` if not. Tell the user to log in to Harnessforce in the browser and choose one Workspace to connect. If the browser does not open, show the user the URL that `harnessforce init` prints. The command waits up to 5 minutes for the login to complete, so run it with a Bash timeout of 10 minutes.
|
|
59
|
+
Follow the choice in step 4: run `harnessforce init --send-content` if selected, or `harnessforce init` if not. Tell the user to log in to Harnessforce in the browser, or sign up, and choose one Workspace to connect. A user without a Workspace can create one on the same page and continue. If the browser does not open, show the user the URL that `harnessforce init` prints. The command waits up to 5 minutes for the login to complete, so run it with a Bash timeout of 10 minutes.
|
|
48
60
|
|
|
49
|
-
For a Workspace that
|
|
61
|
+
For a Workspace that stores no content, `harnessforce init --send-content` writes the content variables as `0`. It prints a message about that and about Collection & retention in the Workspace settings, and exits with code 0. Do not treat this as a failure; show the printed text to the user as it is.
|
|
62
|
+
|
|
63
|
+
If `harnessforce init` ends with the line saying the CLI version is unsupported and exit code 1, run `npm install -g @harnessforce/cli@latest`, then run this step again. If `harnessforce init` fails for any other reason, show what it printed and stop at this step; a failed `harnessforce init` changes neither the keychain nor the settings files.
|
|
50
64
|
|
|
51
65
|
## 6. Import past sessions
|
|
52
66
|
|
|
53
67
|
Run `harnessforce import` and tell the user, as it is, the imported count it prints, along with the breakdown of sessions that were not imported and the guidance. Do not omit the printed reason and the next action even when the count is 0.
|
|
54
68
|
|
|
55
|
-
|
|
69
|
+
Then run `harnessforce status --json`. If `repository.connected` is `false` (the repository of the working directory is not connected), tell the user the following.
|
|
70
|
+
|
|
71
|
+
> This repository is not connected to the GitHub App, so its sessions were not imported. After connecting it, restart Claude Code and run `/harnessforce:setup` to import them.
|
|
56
72
|
|
|
57
|
-
|
|
73
|
+
If `harnessforce import` fails, show its output and go on; importing is not needed for telemetry and can be run later. Before the last line of the ending, show "Importing past sessions failed. Run `harnessforce import` later." If `harnessforce status --json` fails, show its output and go on.
|
|
74
|
+
|
|
75
|
+
## 7. Show the remaining steps
|
|
76
|
+
|
|
77
|
+
From the result of `harnessforce status --json` in step 6, show the steps that are not done (`status` other than `done` in `steps`, and every step in `admin_steps`) in the order of their numbers, each with its title, who does it, and the URL of its page. Use these titles: step 2 "Connect the GitHub App", step 3 "Assign repositories to a project", step 4 "Set up telemetry", step 5 "Wait for your first run". Steps 2 and 3 are done by an owner or admin, and steps 4 and 5 by the user. For a step the user is not responsible for (steps 2 and 3 when the user's `role` is `member`), add "Send this URL to the person responsible."
|
|
78
|
+
|
|
79
|
+
If `has_seat` is `false`, first show "You don't have a developer seat, so the telemetry you send is not stored." If no step remains, show only "Your workspace is ready." If `harnessforce status --json` failed in step 6, show the URL of the Harnessforce Home (the environment variable `HARNESSFORCE_URL`) instead of the steps.
|
|
80
|
+
|
|
81
|
+
## 8. Ask for a restart
|
|
82
|
+
|
|
83
|
+
If the user also uses Codex, tell them first: restart Codex, then run `/hooks` in Codex and trust the harnessforce `SessionStart`, `PostToolUse`, and `Stop` hooks. Codex runs a hook only after the user trusts it, and `harnessforce init` cannot do this for them.
|
|
84
|
+
|
|
85
|
+
Then end with the following.
|
|
58
86
|
|
|
59
87
|
> Harnessforce setup, part 1 of 2, is done. Restart Claude Code, then run `/harnessforce:setup` again in the new session. Part 2 checks that the first event arrived.
|
|
60
88
|
|
|
61
|
-
##
|
|
89
|
+
## 9. Confirm the first event
|
|
90
|
+
|
|
91
|
+
At the start of this session, the plugin hook sent the session to Harnessforce. Run `harnessforce import` again to import the sessions of repositories connected to the GitHub App after step 6; sessions already imported are not sent twice. Tell the user the result as in step 6.
|
|
92
|
+
|
|
93
|
+
Then run `harnessforce status --json`. If step 5 is `done`, show "Your first run arrived." with the URL of the Harnessforce Home (the environment variable `HARNESSFORCE_URL`). If not, show the Home URL and tell the user to check on the waiting screen for the first run that it was received; if nothing arrives after 15 minutes, follow the checks for each cause shown on the waiting screen. Show the steps that are not done in the same way as step 7.
|
|
62
94
|
|
|
63
|
-
|
|
95
|
+
If `harnessforce import` fails, show its output, go on, and show "Importing past sessions failed. Run `harnessforce import` later." before the last line. If `harnessforce status --json` fails, show its output and the Home URL, and still end with the line below: the settings were made in step 5, so not being able to read the status is not a failure of setup.
|
|
64
96
|
|
|
65
97
|
> Harnessforce setup is complete.
|
package/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Register Claude Code sessions with Harnessforce and send their usage summaries",
|
|
2
|
+
"description": "Register Claude Code sessions with Harnessforce and send their usage summaries and work locations",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -60,6 +60,20 @@
|
|
|
60
60
|
"async": true
|
|
61
61
|
}
|
|
62
62
|
]
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"matcher": "^(Write|Edit)$",
|
|
66
|
+
"hooks": [
|
|
67
|
+
{
|
|
68
|
+
"type": "command",
|
|
69
|
+
"command": "node",
|
|
70
|
+
"args": [
|
|
71
|
+
"${CLAUDE_PLUGIN_ROOT}/scripts/harnessforce-hook.cjs",
|
|
72
|
+
"work-location"
|
|
73
|
+
],
|
|
74
|
+
"async": true
|
|
75
|
+
}
|
|
76
|
+
]
|
|
63
77
|
}
|
|
64
78
|
],
|
|
65
79
|
"PostToolUseFailure": [
|
|
@@ -122,6 +136,36 @@
|
|
|
122
136
|
}
|
|
123
137
|
]
|
|
124
138
|
}
|
|
139
|
+
],
|
|
140
|
+
"CwdChanged": [
|
|
141
|
+
{
|
|
142
|
+
"hooks": [
|
|
143
|
+
{
|
|
144
|
+
"type": "command",
|
|
145
|
+
"command": "node",
|
|
146
|
+
"args": [
|
|
147
|
+
"${CLAUDE_PLUGIN_ROOT}/scripts/harnessforce-hook.cjs",
|
|
148
|
+
"cwd-changed"
|
|
149
|
+
],
|
|
150
|
+
"async": true
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
],
|
|
155
|
+
"Stop": [
|
|
156
|
+
{
|
|
157
|
+
"hooks": [
|
|
158
|
+
{
|
|
159
|
+
"type": "command",
|
|
160
|
+
"command": "node",
|
|
161
|
+
"args": [
|
|
162
|
+
"${CLAUDE_PLUGIN_ROOT}/scripts/harnessforce-hook.cjs",
|
|
163
|
+
"stop"
|
|
164
|
+
],
|
|
165
|
+
"async": true
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
}
|
|
125
169
|
]
|
|
126
170
|
}
|
|
127
171
|
}
|
package/package.json
CHANGED
|
@@ -28,8 +28,7 @@ function isInstant(value) {
|
|
|
28
28
|
const nanos = Number(fraction.slice(0, 9) || "0");
|
|
29
29
|
return secondMs >= MIN_INSTANT_MS && !(secondMs > MAX_INSTANT_MS || secondMs === MAX_INSTANT_MS && nanos > 0);
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
//#region ../../packages/semconv/dist/repository.js
|
|
31
|
+
// ../../packages/semconv/dist/repository.js
|
|
33
32
|
const SCHEME_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
34
33
|
const SCP_LIKE = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)(.+)$/;
|
|
35
34
|
const HOST = /^[a-z0-9.-]+$/;
|
|
@@ -63,11 +62,9 @@ function normalizeRepository(remoteUrl) {
|
|
|
63
62
|
const [owner, name] = host === "github.com" ? segments.map((segment) => segment.toLowerCase()) : segments;
|
|
64
63
|
return `${host}/${owner}/${name}`;
|
|
65
64
|
}
|
|
66
|
-
|
|
67
|
-
//#region ../../packages/agent-core/src/object.ts
|
|
65
|
+
// ../../packages/agent-core/src/object.ts
|
|
68
66
|
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
69
|
-
|
|
70
|
-
//#region ../../packages/agent-core/src/config/canonical.ts
|
|
67
|
+
// ../../packages/agent-core/src/config/canonical.ts
|
|
71
68
|
const byCodeUnit$1 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
72
69
|
function canonicalJson(value) {
|
|
73
70
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
@@ -83,8 +80,7 @@ function hashFileContent(content) {
|
|
|
83
80
|
function sortComponents(components) {
|
|
84
81
|
return [...components].sort((a, b) => byCodeUnit$1(a.kind, b.kind) || byCodeUnit$1(a.source, b.source) || byCodeUnit$1(a.id, b.id));
|
|
85
82
|
}
|
|
86
|
-
|
|
87
|
-
//#region ../../packages/agent-core/src/config/files.ts
|
|
83
|
+
// ../../packages/agent-core/src/config/files.ts
|
|
88
84
|
const MAX_DEPTH = 8;
|
|
89
85
|
const MAX_FILE_BYTES = 1048576;
|
|
90
86
|
var CollectionExpired = class extends Error {};
|
|
@@ -145,8 +141,7 @@ async function listDirectories(dir, guard) {
|
|
|
145
141
|
for (const entry of await entries(dir, guard)) if (await kindOf(dir, entry, guard) === "directory") names.push(entry.name);
|
|
146
142
|
return names;
|
|
147
143
|
}
|
|
148
|
-
|
|
149
|
-
//#region ../../packages/agent-core/src/config/hash-input.ts
|
|
144
|
+
// ../../packages/agent-core/src/config/hash-input.ts
|
|
150
145
|
function withoutSecrets(entry) {
|
|
151
146
|
const { env, headers, url, args: _args, ...rest } = entry;
|
|
152
147
|
const result = rest;
|
|
@@ -178,8 +173,7 @@ function hashInput(kind, value) {
|
|
|
178
173
|
if (kind === "hook" && Array.isArray(value)) return value.map(reduceHookGroup);
|
|
179
174
|
return value;
|
|
180
175
|
}
|
|
181
|
-
|
|
182
|
-
//#region ../../packages/agent-core/src/config/scope.ts
|
|
176
|
+
// ../../packages/agent-core/src/config/scope.ts
|
|
183
177
|
const MAX_COMPONENTS = 1e3;
|
|
184
178
|
var TooManyComponents = class extends Error {};
|
|
185
179
|
var Sink = class {
|
|
@@ -235,8 +229,7 @@ function collectMcpServers(s, servers) {
|
|
|
235
229
|
if (!isObject(servers)) return;
|
|
236
230
|
for (const [name, value] of Object.entries(servers)) if (isObject(value)) s.addValue("mcp_server", name, value);
|
|
237
231
|
}
|
|
238
|
-
|
|
239
|
-
//#region ../../packages/agent-core/src/hook-warning.ts
|
|
232
|
+
// ../../packages/agent-core/src/hook-warning.ts
|
|
240
233
|
const RESTART_FAILED_WARNING = "Could not restart the Harnessforce hook";
|
|
241
234
|
const INVALID_ENDPOINT_WARNING = "The Harnessforce destination is invalid. Run `harnessforce init` again";
|
|
242
235
|
const REGISTRATION_FAILED_WARNING = "Harnessforce session registration failed. Check your network connection";
|
|
@@ -250,8 +243,7 @@ const DESTINATION_MISMATCH_WARNING = {
|
|
|
250
243
|
};
|
|
251
244
|
const TELEMETRY_NOT_CONFIGURED_WARNING = "Claude Code telemetry for Harnessforce is not configured. Run `harnessforce init`";
|
|
252
245
|
const warningOnlyOutput = (message) => `${JSON.stringify({ systemMessage: message })}\n`;
|
|
253
|
-
|
|
254
|
-
//#region ../../packages/agent-core/src/managed.ts
|
|
246
|
+
// ../../packages/agent-core/src/managed.ts
|
|
255
247
|
const MANAGED_DIRS = {
|
|
256
248
|
darwin: "/Library/Application Support/ClaudeCode",
|
|
257
249
|
win32: "C:\\Program Files\\ClaudeCode"
|
|
@@ -270,8 +262,7 @@ async function readManagedEnv(managedDir, names) {
|
|
|
270
262
|
}
|
|
271
263
|
return values;
|
|
272
264
|
}
|
|
273
|
-
|
|
274
|
-
//#region ../../packages/agent-core/src/plugin-auto-update.ts
|
|
265
|
+
// ../../packages/agent-core/src/plugin-auto-update.ts
|
|
275
266
|
const MARKETPLACE_NAME = "harnessforce-agent";
|
|
276
267
|
const MARKETPLACE_KEYS = ["extraKnownMarketplaces", "additionalMarketplaces"];
|
|
277
268
|
const DISABLING_VARIABLES = [
|
|
@@ -314,8 +305,7 @@ async function isPluginAutoUpdateEffective(options) {
|
|
|
314
305
|
const entry = marketplacesOf(await readJsonObjectFile(options.userSettingsPath, noGuard$1))?.[MARKETPLACE_NAME];
|
|
315
306
|
return isObject(entry) && entry.autoUpdate === true;
|
|
316
307
|
}
|
|
317
|
-
|
|
318
|
-
//#region ../../packages/agent-core/src/process/lookup.ts
|
|
308
|
+
// ../../packages/agent-core/src/process/lookup.ts
|
|
319
309
|
const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
|
|
320
310
|
const NPM_SHIM_MAX_BYTES = 65536;
|
|
321
311
|
const SHEBANG_MAX_BYTES = 256;
|
|
@@ -496,8 +486,7 @@ async function commandLineFor(file, args, context, { quoteArgs = true } = {}) {
|
|
|
496
486
|
verbatim: true
|
|
497
487
|
};
|
|
498
488
|
}
|
|
499
|
-
|
|
500
|
-
//#region ../../packages/agent-core/src/process/git.ts
|
|
489
|
+
// ../../packages/agent-core/src/process/git.ts
|
|
501
490
|
const GIT_TIMEOUT_MS = 1e3;
|
|
502
491
|
const execGit = (file, args, env, timeoutMs) => new Promise((resolve) => {
|
|
503
492
|
(0, node_child_process.execFile)(file, args, {
|
|
@@ -535,8 +524,7 @@ function createGitRunner({ platform, env, processCwd, excludeTarget, timeoutMs =
|
|
|
535
524
|
], gitEnv, timeoutMs);
|
|
536
525
|
};
|
|
537
526
|
}
|
|
538
|
-
|
|
539
|
-
//#region ../../packages/agent-core/src/process/runtime-env.ts
|
|
527
|
+
// ../../packages/agent-core/src/process/runtime-env.ts
|
|
540
528
|
const RUNTIME_VARIABLES = [
|
|
541
529
|
"NODE_TLS_REJECT_UNAUTHORIZED",
|
|
542
530
|
"NODE_EXTRA_CA_CERTS",
|
|
@@ -600,8 +588,7 @@ function relaunchWithoutRuntimeVariables({ platform, env, stash, spawnSelf: star
|
|
|
600
588
|
}));
|
|
601
589
|
});
|
|
602
590
|
}
|
|
603
|
-
|
|
604
|
-
//#region ../../packages/agent-core/src/telemetry-settings.ts
|
|
591
|
+
// ../../packages/agent-core/src/telemetry-settings.ts
|
|
605
592
|
const EXPECTED = {
|
|
606
593
|
CLAUDE_CODE_ENABLE_TELEMETRY: (value) => value === "1",
|
|
607
594
|
OTEL_LOGS_EXPORTER: (value) => includesOtlp(value),
|
|
@@ -621,8 +608,7 @@ async function isTelemetryConfigured(options) {
|
|
|
621
608
|
return typeof value === "string" && EXPECTED[name](value);
|
|
622
609
|
}) && typeof settings?.otelHeadersHelper === "string";
|
|
623
610
|
}
|
|
624
|
-
|
|
625
|
-
//#region ../../packages/agent-core/src/url.ts
|
|
611
|
+
// ../../packages/agent-core/src/url.ts
|
|
626
612
|
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
627
613
|
"localhost",
|
|
628
614
|
"127.0.0.1",
|
|
@@ -643,8 +629,7 @@ function underBase(base, path) {
|
|
|
643
629
|
url.pathname = `${base.pathname.replace(/\/+$/, "")}/${path}`;
|
|
644
630
|
return url;
|
|
645
631
|
}
|
|
646
|
-
|
|
647
|
-
//#region ../../packages/agent-core/src/client-update.ts
|
|
632
|
+
// ../../packages/agent-core/src/client-update.ts
|
|
648
633
|
const CLIENT_UPDATE_HEADER = "harnessforce-client-update";
|
|
649
634
|
const UPDATE_COMMANDS = {
|
|
650
635
|
cli: "npm install -g @harnessforce/cli@latest",
|
|
@@ -803,8 +788,7 @@ async function recheckClientStatus(options) {
|
|
|
803
788
|
}
|
|
804
789
|
return await readUpdateState(options.statePath, options.version) ?? state;
|
|
805
790
|
}
|
|
806
|
-
|
|
807
|
-
//#region ../../packages/agent-core/src/config/plugins.ts
|
|
791
|
+
// ../../packages/agent-core/src/config/plugins.ts
|
|
808
792
|
const NON_MARKETPLACE_ORIGINS = /* @__PURE__ */ new Set([
|
|
809
793
|
"inline",
|
|
810
794
|
"skills-dir",
|
|
@@ -845,8 +829,7 @@ async function collectPlugins(pluginsRoot, settingsByPrecedence, scope, guard) {
|
|
|
845
829
|
collectMcpServers(s, (await readJsonObjectFile((0, node_path.join)(root, ".mcp.json"), guard))?.mcpServers);
|
|
846
830
|
}
|
|
847
831
|
}
|
|
848
|
-
|
|
849
|
-
//#region ../../packages/agent-core/src/config/collect.ts
|
|
832
|
+
// ../../packages/agent-core/src/config/collect.ts
|
|
850
833
|
const COLLECT_BUDGET_MS = 1e3;
|
|
851
834
|
async function readManagedSettings(managedDir, guard) {
|
|
852
835
|
let merged = await readJsonObjectFile((0, node_path.join)(managedDir, "managed-settings.json"), guard);
|
|
@@ -980,8 +963,7 @@ async function collectConfig(options) {
|
|
|
980
963
|
components: sortComponents(sink.components)
|
|
981
964
|
};
|
|
982
965
|
}
|
|
983
|
-
|
|
984
|
-
//#region ../../packages/agent-core/src/ingest.ts
|
|
966
|
+
// ../../packages/agent-core/src/ingest.ts
|
|
985
967
|
const SEND_TIMEOUT_MS = 2e3;
|
|
986
968
|
const INGEST_KEY_WORKSPACE = /^hf_ik_([A-Za-z0-9-]{1,128})_/;
|
|
987
969
|
const workspaceIdOf = (key) => INGEST_KEY_WORKSPACE.exec(key)?.[1];
|
|
@@ -992,6 +974,7 @@ function ingestBaseFrom(endpoint) {
|
|
|
992
974
|
ingestBase.pathname = base.pathname.replace(/\/+$/, "");
|
|
993
975
|
return ingestBase;
|
|
994
976
|
}
|
|
977
|
+
const postItem = (destination, path, item, fetchImpl, timeoutMs = SEND_TIMEOUT_MS) => postItems(destination, path, [item], fetchImpl, timeoutMs);
|
|
995
978
|
async function postItems(destination, path, items, fetchImpl, timeoutMs = SEND_TIMEOUT_MS) {
|
|
996
979
|
try {
|
|
997
980
|
const response = await fetchImpl(underBase(destination.ingestBase, path), {
|
|
@@ -1017,21 +1000,17 @@ async function postItems(destination, path, items, fetchImpl, timeoutMs = SEND_T
|
|
|
1017
1000
|
};
|
|
1018
1001
|
}
|
|
1019
1002
|
}
|
|
1020
|
-
|
|
1021
|
-
//#region ../../packages/agent-core/src/issue.ts
|
|
1003
|
+
// ../../packages/agent-core/src/issue.ts
|
|
1022
1004
|
const isIssueIdentifier = (value) => isToken(value, 300);
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
//#endregion
|
|
1027
|
-
//#region ../../packages/agent-core/src/session-context.ts
|
|
1005
|
+
// ../../packages/agent-core/src/using-harnessforce.ts
|
|
1006
|
+
const usingHarnessforceBody = "---\nname: using-harnessforce\ndescription: Use when starting a session in an environment with the Harnessforce hook, when starting work on an Issue, or when asked what is recorded or collected. The hook injects this text into the context at session start.\n---\n\n# Using Harnessforce\n\nThis session has the Harnessforce hook installed. Harnessforce links the work of people and AI to Issues and records it. Follow these rules.\n\n## What is recorded\n\n- Session registration: agent, session ID, repository, branch, commit, start time, and the first prompt ID.\n- Work locations: the repository and branch of each Git working tree the session works in. Paths are not sent.\n- Config snapshot: the structure of the configuration in use (Claude Code only).\n- OTel telemetry: what the user chooses to send.\n- For work on an Issue, `record-run` registers the session as a Run and closes it. It records no Plan, Decision, or Definition of Done.\n\n## What is not collected\n\n- Prompts, responses, tool input and output, and the body of code diffs arrive only when the user sends them (`--send-content` or managed settings) and the Workspace stores that category. The members the Workspace chooses can read them. `harnessforce import` never sends them.\n- Never put these contents, secrets, or environment variable values in MCP tool input or in a record.\n\n## Which hook sends what\n\n- SessionStart: session registration and the config snapshot (Codex sends session registration only).\n- UserPromptSubmit: resends on the first prompt (Claude Code only).\n- Stop: sends the work locations at the end of a turn in which they changed.\n- A failing hook never stops the session. When the hook cannot send, it shows a one-line warning. Tell the user when a warning appears.\n\n## Which skill and tool to use, and when\n\n- For work on a specified Issue ID, or when the environment variable `HARNESSFORCE_ISSUE` is set, follow `record-run` and use `start_run`, `get_issue`, and `complete_run`. An agent that cannot load the skill calls the tools in the same order.\n- Pass the value of the `harnessforce session_id:` line in the context to `start_run`. If there are several, use the last one. If the line is missing, do not guess the session ID, and do not call `start_run` or `complete_run`.\n- Use `propose-improvements` for the analysis of `/harnessforce:tune`.\n- When no Issue is specified, do not start recording a Run on your own. For work that commits changes, follow \"When no Issue is specified\" in `record-run` and ask the user once whether to link it to an existing Issue.\n\n## Red Flags\n\nStop when one of these thoughts comes up.\n\n| Thought | Reality |\n| --- | --- |\n| An Issue was specified, but the work is small, so recording is unnecessary | Work on a specified Issue is recorded regardless of its size |\n| The session ID line is missing, so I will guess it and call `start_run` | Do not guess. The Run would no longer be linked to the session |\n| Summarizing the content before recording would help | Do not pass content to the tool. It goes beyond what is collected |\n| A warning appeared, but I can ignore it | Sending is not working. Tell the user |\n\n## Priority\n\n1. The user's instructions (CLAUDE.md, AGENTS.md, direct requests). If the user tells you to stop recording, stop recording the Run.\n2. This introduction.\n3. Default behavior.\n\n## Subagents\n\nA subagent that was delegated only a specific task by its parent does not register a Run by following this introduction. The parent registers the Run.\n".replace(/\r\n/g, "\n").replace(/^---\n[\s\S]*?\n---\n+/, "").trimEnd();
|
|
1007
|
+
// ../../packages/agent-core/src/session-context.ts
|
|
1028
1008
|
const sessionContextLine = (sessionId) => `harnessforce session_id: ${sessionId}`;
|
|
1029
1009
|
const sessionContext = (sessionId) => ({ hookSpecificOutput: {
|
|
1030
1010
|
hookEventName: "SessionStart",
|
|
1031
1011
|
additionalContext: `${sessionContextLine(sessionId)}\n\n${usingHarnessforceBody}`
|
|
1032
1012
|
} });
|
|
1033
|
-
|
|
1034
|
-
//#region ../../packages/agent-core/src/vcs.ts
|
|
1013
|
+
// ../../packages/agent-core/src/vcs.ts
|
|
1035
1014
|
async function selectRemote(cwd, git) {
|
|
1036
1015
|
if (await git(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true") return void 0;
|
|
1037
1016
|
const remotes = (await git(cwd, ["remote"]))?.split("\n") ?? [];
|
|
@@ -1070,8 +1049,120 @@ async function resolveProjectRoot(cwd, git) {
|
|
|
1070
1049
|
const topLevel = await git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
1071
1050
|
return topLevel && (0, node_path.isAbsolute)(topLevel) ? topLevel : cwd;
|
|
1072
1051
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1052
|
+
// ../../packages/agent-core/src/work-location.ts
|
|
1053
|
+
const absolute = (value) => typeof value === "string" && (0, node_path.isAbsolute)(value) ? value : void 0;
|
|
1054
|
+
const toolInputOf = (fields) => isObject(fields.tool_input) ? fields.tool_input : {};
|
|
1055
|
+
const PATCH_FILE_LINE = /^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/;
|
|
1056
|
+
function patchedDirectories(command, cwd) {
|
|
1057
|
+
if (typeof command !== "string") return [];
|
|
1058
|
+
return command.split(/\r?\n/).flatMap((line) => {
|
|
1059
|
+
const path = PATCH_FILE_LINE.exec(line)?.[1]?.trim();
|
|
1060
|
+
return path ? [(0, node_path.dirname)((0, node_path.resolve)(cwd, path))] : [];
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* eventの入力から、作業位置を求めるdirectoryを返す。読むのは`new_cwd`、`file_path`、patchのfileの行のpathだけで、編集の
|
|
1065
|
+
* 本文を読まない。
|
|
1066
|
+
*/
|
|
1067
|
+
function observedDirectories(agent, event, fields, cwd) {
|
|
1068
|
+
if (event === "stop") return [cwd];
|
|
1069
|
+
if (agent === "claude_code" && event === "cwd-changed") {
|
|
1070
|
+
const newCwd = absolute(fields.new_cwd);
|
|
1071
|
+
return newCwd ? [newCwd] : [];
|
|
1072
|
+
}
|
|
1073
|
+
if (event !== "post-tool-use") return [];
|
|
1074
|
+
const input = toolInputOf(fields);
|
|
1075
|
+
if (agent === "codex") return fields.tool_name === "apply_patch" ? [...new Set(patchedDirectories(input.command, cwd))] : [];
|
|
1076
|
+
if (fields.tool_name !== "Write" && fields.tool_name !== "Edit") return [];
|
|
1077
|
+
const filePath = absolute(input.file_path);
|
|
1078
|
+
return filePath ? [(0, node_path.dirname)(filePath)] : [];
|
|
1079
|
+
}
|
|
1080
|
+
const isDirectory = (path) => (0, node_fs_promises.stat)(path).then((found) => found.isDirectory(), () => false);
|
|
1081
|
+
/** directoryのworking treeの、その時点のrepositoryとbranch。repositoryかbranchを求められなければundefined。 */
|
|
1082
|
+
async function observePair(directory, git) {
|
|
1083
|
+
if (!await isDirectory(directory)) return void 0;
|
|
1084
|
+
const vcs = await resolveVcs(directory, git);
|
|
1085
|
+
return vcs?.repository && vcs.branch ? {
|
|
1086
|
+
repository: vcs.repository,
|
|
1087
|
+
branch: vcs.branch
|
|
1088
|
+
} : void 0;
|
|
1089
|
+
}
|
|
1090
|
+
const emptyLocationState = () => ({
|
|
1091
|
+
pairs: [],
|
|
1092
|
+
sent: [],
|
|
1093
|
+
unauthorized: false
|
|
1094
|
+
});
|
|
1095
|
+
const pairKey = (pair) => JSON.stringify([pair.repository, pair.branch]);
|
|
1096
|
+
/** 組を加える。記録済みの組と、10件に達した後の組は加えない。 */
|
|
1097
|
+
function withObservedPair(state, pair) {
|
|
1098
|
+
if (state.pairs.some((saved) => pairKey(saved) === pairKey(pair)) || state.pairs.length >= 10) return state;
|
|
1099
|
+
return {
|
|
1100
|
+
...state,
|
|
1101
|
+
pairs: [...state.pairs, pair]
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
/** 記録した組の集合が、最後に送った集合と異なる場合だけ送る組。 */
|
|
1105
|
+
function locationsToSend(state) {
|
|
1106
|
+
if (state.unauthorized || state.pairs.length === 0) return void 0;
|
|
1107
|
+
const sent = new Set(state.sent.map(pairKey));
|
|
1108
|
+
return sent.size === state.pairs.length && state.pairs.every((pair) => sent.has(pairKey(pair))) ? void 0 : state.pairs;
|
|
1109
|
+
}
|
|
1110
|
+
const isPair = (value) => isObject(value) && typeof value.repository === "string" && typeof value.branch === "string";
|
|
1111
|
+
async function readLocationState(path) {
|
|
1112
|
+
try {
|
|
1113
|
+
const saved = JSON.parse(await (0, node_fs_promises.readFile)(path, "utf8"));
|
|
1114
|
+
if (!isObject(saved) || !Array.isArray(saved.pairs) || !Array.isArray(saved.sent)) return emptyLocationState();
|
|
1115
|
+
return {
|
|
1116
|
+
pairs: saved.pairs.filter(isPair),
|
|
1117
|
+
sent: saved.sent.filter(isPair),
|
|
1118
|
+
unauthorized: saved.unauthorized === true
|
|
1119
|
+
};
|
|
1120
|
+
} catch {
|
|
1121
|
+
return emptyLocationState();
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
async function writeLocationState(path, state) {
|
|
1125
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
1126
|
+
await (0, node_fs_promises.writeFile)(temporary, JSON.stringify(state), { mode: 384 });
|
|
1127
|
+
await (0, node_fs_promises.rename)(temporary, path);
|
|
1128
|
+
}
|
|
1129
|
+
const FILE_SAFE_SESSION_ID$1 = /^[A-Za-z0-9_-]+$/;
|
|
1130
|
+
/** Claude Codeの記録のfile。`scratchpad_dir`が無ければ記録も送信もしない。 */
|
|
1131
|
+
const claudeLocationStatePath = (scratchpadDir, sessionId) => FILE_SAFE_SESSION_ID$1.test(sessionId) ? (0, node_path.join)(scratchpadDir, `locations-${sessionId}.json`) : void 0;
|
|
1132
|
+
/** 観測したdirectoryの組を記録のfileへ加える。gitの外のdirectoryは記録しない。 */
|
|
1133
|
+
async function recordObservedPairs(statePath, directories, deps) {
|
|
1134
|
+
const pairs = await Promise.all(directories.map((directory) => observePair(directory, deps.git)));
|
|
1135
|
+
const before = await readLocationState(statePath);
|
|
1136
|
+
const after = pairs.reduce((state, pair) => pair ? withObservedPair(state, pair) : state, before);
|
|
1137
|
+
if (after !== before) await writeLocationState(statePath, after);
|
|
1138
|
+
return after;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* 記録した組の集合が最後に送った集合と異なれば`/v1/sessions/locations`へ送る。2xxなら送った集合を記録し、401なら以後送らない
|
|
1142
|
+
* ことを記録する。それ以外の失敗では記録を変えず、次の`Stop`が送り直しを兼ねる。
|
|
1143
|
+
*/
|
|
1144
|
+
async function sendLocations(statePath, state, item, destination, fetchImpl) {
|
|
1145
|
+
const locations = locationsToSend(state);
|
|
1146
|
+
if (!locations) return { kind: "unchanged" };
|
|
1147
|
+
const outcome = await postItem(destination, "v1/sessions/locations", {
|
|
1148
|
+
agent: item.agent,
|
|
1149
|
+
session_id: item.sessionId,
|
|
1150
|
+
locations
|
|
1151
|
+
}, fetchImpl);
|
|
1152
|
+
if (outcome.kind === "accepted") await writeLocationState(statePath, {
|
|
1153
|
+
...state,
|
|
1154
|
+
sent: locations
|
|
1155
|
+
});
|
|
1156
|
+
if (outcome.kind === "unauthorized") await writeLocationState(statePath, {
|
|
1157
|
+
...state,
|
|
1158
|
+
unauthorized: true
|
|
1159
|
+
});
|
|
1160
|
+
return {
|
|
1161
|
+
kind: "sent",
|
|
1162
|
+
outcome
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
// src/input.ts
|
|
1075
1166
|
const FILE_SAFE_SESSION_ID = /^[A-Za-z0-9_-]+$/;
|
|
1076
1167
|
const isFileSafeSessionId = (sessionId) => FILE_SAFE_SESSION_ID.test(sessionId);
|
|
1077
1168
|
const token = (value) => typeof value === "string" && isToken(value) ? value : void 0;
|
|
@@ -1102,8 +1193,7 @@ function parseHookInput(raw) {
|
|
|
1102
1193
|
fields
|
|
1103
1194
|
};
|
|
1104
1195
|
}
|
|
1105
|
-
|
|
1106
|
-
//#region src/scratchpad.ts
|
|
1196
|
+
// src/scratchpad.ts
|
|
1107
1197
|
const registrationFile = (pad) => (0, node_path.join)(pad.dir, `registration-${pad.sessionId}.json`);
|
|
1108
1198
|
const unauthorizedMark = (pad) => (0, node_path.join)(pad.dir, `unauthorized-${pad.sessionId}`);
|
|
1109
1199
|
const firstPromptSentMark = (pad) => (0, node_path.join)(pad.dir, `first-prompt-sent-${pad.sessionId}`);
|
|
@@ -1130,8 +1220,7 @@ async function isFirstPromptSent(pad) {
|
|
|
1130
1220
|
async function claimFirstPrompt(pad) {
|
|
1131
1221
|
return (0, node_fs_promises.writeFile)(firstPromptSentMark(pad), "", { flag: "wx" }).then(() => true, () => false);
|
|
1132
1222
|
}
|
|
1133
|
-
|
|
1134
|
-
//#region src/usage/names.ts
|
|
1223
|
+
// src/usage/names.ts
|
|
1135
1224
|
const PLUGIN_COMPONENT = /^([A-Za-z0-9._-]+)@[A-Za-z0-9._-]+:(.+)$/;
|
|
1136
1225
|
function pluginPartsOf(identifier) {
|
|
1137
1226
|
const parts = identifier.plugin ? PLUGIN_COMPONENT.exec(identifier.id) : null;
|
|
@@ -1167,8 +1256,7 @@ const mcpServerOfTool = (toolName, servers) => onlyOne(servers.filter((server) =
|
|
|
1167
1256
|
const prefix = toolPrefixOf(server);
|
|
1168
1257
|
return prefix !== void 0 && toolName.startsWith(prefix);
|
|
1169
1258
|
}));
|
|
1170
|
-
|
|
1171
|
-
//#region src/usage/count.ts
|
|
1259
|
+
// src/usage/count.ts
|
|
1172
1260
|
const COUNTING_EVENTS = [
|
|
1173
1261
|
"post-tool-use",
|
|
1174
1262
|
"post-tool-use-failure",
|
|
@@ -1218,8 +1306,7 @@ function countedOf(event, fields, identifiers) {
|
|
|
1218
1306
|
} : void 0;
|
|
1219
1307
|
}
|
|
1220
1308
|
}
|
|
1221
|
-
|
|
1222
|
-
//#region src/usage/store.ts
|
|
1309
|
+
// src/usage/store.ts
|
|
1223
1310
|
function usageStoreOf(sessionId, env) {
|
|
1224
1311
|
const data = env.CLAUDE_PLUGIN_DATA;
|
|
1225
1312
|
if (!data || !(0, node_path.isAbsolute)(data) || !isFileSafeSessionId(sessionId)) return void 0;
|
|
@@ -1338,8 +1425,7 @@ async function removeExpiredFiles(store, now) {
|
|
|
1338
1425
|
if (await (0, node_fs_promises.unlink)(file).then(() => true, () => false)) removed += 1;
|
|
1339
1426
|
}
|
|
1340
1427
|
}
|
|
1341
|
-
|
|
1342
|
-
//#region src/usage/summary.ts
|
|
1428
|
+
// src/usage/summary.ts
|
|
1343
1429
|
const COLLECTOR_VERSION = "0.1.0";
|
|
1344
1430
|
const MAX_ITEMS = 200;
|
|
1345
1431
|
const byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
@@ -1416,8 +1502,7 @@ function buildSummary(sessionId, lines) {
|
|
|
1416
1502
|
}
|
|
1417
1503
|
};
|
|
1418
1504
|
}
|
|
1419
|
-
|
|
1420
|
-
//#region src/usage/send.ts
|
|
1505
|
+
// src/usage/send.ts
|
|
1421
1506
|
async function sendSessionUsage(store, destination, deadlineMs, deps) {
|
|
1422
1507
|
const state = await readState(store);
|
|
1423
1508
|
if (!state || state.workspaceId !== workspaceIdOf(destination.key)) return void 0;
|
|
@@ -1470,8 +1555,7 @@ async function sendUnsentUsage(store, destination, deps) {
|
|
|
1470
1555
|
if (outcome.kind === "accepted") await Promise.all(batch.map((sent) => writeSentLength(store, sent.sessionId, sent.state, sent.record.length)));
|
|
1471
1556
|
return outcome;
|
|
1472
1557
|
}
|
|
1473
|
-
|
|
1474
|
-
//#region src/hook.ts
|
|
1558
|
+
// src/hook.ts
|
|
1475
1559
|
const pluginStatePath = (env) => env.CLAUDE_PLUGIN_DATA && (0, node_path.isAbsolute)(env.CLAUDE_PLUGIN_DATA) ? (0, node_path.join)(env.CLAUDE_PLUGIN_DATA, "update-status.json") : void 0;
|
|
1476
1560
|
const cliStatePath = (homeDir) => (0, node_path.join)(homeDir, ".harnessforce", "update-status.json");
|
|
1477
1561
|
const CLI_UNSUPPORTED_WARNING = unsupportedLine("cli");
|
|
@@ -1750,10 +1834,43 @@ const onCountingEvent = (event) => async (input, deps) => {
|
|
|
1750
1834
|
...counted
|
|
1751
1835
|
});
|
|
1752
1836
|
};
|
|
1837
|
+
const locationStatePathOf = (input) => input.scratchpad ? claudeLocationStatePath(input.scratchpad.dir, input.sessionId) : void 0;
|
|
1838
|
+
/**
|
|
1839
|
+
* correlation.md「sessionの作業位置」: 観測した時点のworking treeのrepositoryとbranchを記録する。`Stop`は加えて、記録した組の
|
|
1840
|
+
* 集合が最後に送った集合と異なれば送る。stdoutへ何も書かず、`CwdChanged`ではstderrにも書かない。
|
|
1841
|
+
*/
|
|
1842
|
+
const onLocationEvent = (event) => async (input, deps) => {
|
|
1843
|
+
const statePath = locationStatePathOf(input);
|
|
1844
|
+
if (!statePath) return;
|
|
1845
|
+
const state = await recordObservedPairs(statePath, observedDirectories("claude_code", event, input.fields, input.cwd), deps);
|
|
1846
|
+
if (event !== "stop" || !input.scratchpad) return;
|
|
1847
|
+
if (await isMarkedUnauthorized(input.scratchpad)) return;
|
|
1848
|
+
if (await isPluginBlocked(deps, false)) return;
|
|
1849
|
+
const { destination } = await resolveDestination(deps, input.cwd);
|
|
1850
|
+
if (!destination) return;
|
|
1851
|
+
const sent = await sendLocations(statePath, state, {
|
|
1852
|
+
agent: "claude_code",
|
|
1853
|
+
sessionId: input.sessionId
|
|
1854
|
+
}, destination, deps.fetch);
|
|
1855
|
+
if (sent.kind !== "sent" || sent.outcome.kind === "accepted") return;
|
|
1856
|
+
if (sent.outcome.kind === "unauthorized") {
|
|
1857
|
+
await markUnauthorized(input.scratchpad).catch(logError(deps));
|
|
1858
|
+
deps.stderr("harnessforce: session location failed (unauthorized)\n");
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
deps.stderr(`harnessforce: session location failed (${sent.outcome.reason})\n`);
|
|
1862
|
+
};
|
|
1863
|
+
const silently = (handler) => (input, deps) => handler(input, {
|
|
1864
|
+
...deps,
|
|
1865
|
+
stderr: () => {}
|
|
1866
|
+
}).catch(() => {});
|
|
1753
1867
|
const HANDLERS = new Map([
|
|
1754
1868
|
["session-start", onSessionStart],
|
|
1755
1869
|
["user-prompt-submit", onUserPromptSubmit],
|
|
1756
1870
|
["session-end", onSessionEnd],
|
|
1871
|
+
["cwd-changed", silently(onLocationEvent("cwd-changed"))],
|
|
1872
|
+
["work-location", onLocationEvent("post-tool-use")],
|
|
1873
|
+
["stop", onLocationEvent("stop")],
|
|
1757
1874
|
...COUNTING_EVENTS.map((event) => [event, onCountingEvent(event)])
|
|
1758
1875
|
]);
|
|
1759
1876
|
async function runHook(event, rawInput, hookDeps) {
|
|
@@ -1767,8 +1884,7 @@ async function runHook(event, rawInput, hookDeps) {
|
|
|
1767
1884
|
};
|
|
1768
1885
|
await handler(input, deps).catch(logError(deps));
|
|
1769
1886
|
}
|
|
1770
|
-
|
|
1771
|
-
//#region src/user-key.ts
|
|
1887
|
+
// src/user-key.ts
|
|
1772
1888
|
const HF_TIMEOUT_MS = 1e3;
|
|
1773
1889
|
const HF_OUTPUT_LIMIT_BYTES = 65536;
|
|
1774
1890
|
const BEARER = /^Bearer (\S+)$/;
|
|
@@ -1829,8 +1945,7 @@ const execHf = (file, args, options) => new Promise((resolve) => {
|
|
|
1829
1945
|
...error && !error.killed && typeof error.code === "number" ? { exitCode: error.code } : {}
|
|
1830
1946
|
}));
|
|
1831
1947
|
});
|
|
1832
|
-
|
|
1833
|
-
//#region src/main.ts
|
|
1948
|
+
// src/main.ts
|
|
1834
1949
|
delete process.env.HARNESSFORCE_RUNTIME_ENV;
|
|
1835
1950
|
async function readStdin() {
|
|
1836
1951
|
const chunks = [];
|
|
@@ -1880,7 +1995,7 @@ async function main() {
|
|
|
1880
1995
|
}),
|
|
1881
1996
|
client: {
|
|
1882
1997
|
component: "claude-code-plugin",
|
|
1883
|
-
version: "0.
|
|
1998
|
+
version: "0.3.1",
|
|
1884
1999
|
dev: false
|
|
1885
2000
|
},
|
|
1886
2001
|
isPluginAutoUpdateEffective: (cwd) => isPluginAutoUpdateEffective({
|
|
@@ -1892,4 +2007,3 @@ async function main() {
|
|
|
1892
2007
|
});
|
|
1893
2008
|
}
|
|
1894
2009
|
main();
|
|
1895
|
-
//#endregion
|
|
@@ -9,13 +9,26 @@ Use the tools of the Harnessforce MCP server to link the work to the Issue. The
|
|
|
9
9
|
|
|
10
10
|
## At the start of the work
|
|
11
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`.
|
|
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`. Do not pass the repository, branch, or commit: the hook registers the same session and fills them in. 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
13
|
2. Check the Issue with `get_issue`.
|
|
14
14
|
|
|
15
15
|
## When the session ID changes
|
|
16
16
|
|
|
17
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
18
|
|
|
19
|
+
## When no Issue is specified
|
|
20
|
+
|
|
21
|
+
Ask the user once whether to link the work to an existing Issue, only when all of the following hold. Do not create an Issue: Issues are GitHub Issues.
|
|
22
|
+
|
|
23
|
+
- The user has not given an Issue identifier. If the current branch name contains an identifier (a branch name `gh-<number>` is passed as `<owner>/<repo>#<number>` of the repository) and `get_issue` resolves it, treat it as the specified Issue and do not ask. If `get_issue` returns `not_found`, treat it as no identifier.
|
|
24
|
+
- The work commits changes. Do not ask for research, answering questions, or conversations that only plan.
|
|
25
|
+
- The context has a `harnessforce session_id:` line.
|
|
26
|
+
- You have not asked yet for the last `harnessforce session_id:` value in the context. If the user declines, do not ask again while the value stays the same. After a fork or `/clear` changes the value, you may ask again.
|
|
27
|
+
|
|
28
|
+
To show the candidates, pick up to 3 words from the summary of the work that are likely to appear in an Issue title, and call `list_issues` once per word with `q` set to `-status:done,canceled <word>` and `sort` set to `updated`. Merge the results, remove duplicates, and show at most 5 Issues, most recently updated first. If the user picks one, call `start_run` with that Issue. If there is no candidate or the user picks none, continue the work without calling `start_run`.
|
|
29
|
+
|
|
30
|
+
If `start_run` returns `forbidden` (a view-only token or a view-only Workspace), tell the user the reason as it is, and do not ask again for that session ID.
|
|
31
|
+
|
|
19
32
|
## At the end of the work
|
|
20
33
|
|
|
21
34
|
- Call `complete_run` with the `run_id`.
|
|
@@ -10,19 +10,21 @@ This session has the Harnessforce hook installed. Harnessforce links the work of
|
|
|
10
10
|
## What is recorded
|
|
11
11
|
|
|
12
12
|
- Session registration: agent, session ID, repository, branch, commit, start time, and the first prompt ID.
|
|
13
|
+
- Work locations: the repository and branch of each Git working tree the session works in. Paths are not sent.
|
|
13
14
|
- Config snapshot: the structure of the configuration in use (Claude Code only).
|
|
14
15
|
- OTel telemetry: what the user chooses to send.
|
|
15
16
|
- For work on an Issue, `record-run` registers the session as a Run and closes it. It records no Plan, Decision, or Definition of Done.
|
|
16
17
|
|
|
17
18
|
## What is not collected
|
|
18
19
|
|
|
19
|
-
- Prompts, responses, tool input and output, and the body of code diffs arrive only when the user
|
|
20
|
+
- Prompts, responses, tool input and output, and the body of code diffs arrive only when the user sends them (`--send-content` or managed settings) and the Workspace stores that category. The members the Workspace chooses can read them. `harnessforce import` never sends them.
|
|
20
21
|
- Never put these contents, secrets, or environment variable values in MCP tool input or in a record.
|
|
21
22
|
|
|
22
23
|
## Which hook sends what
|
|
23
24
|
|
|
24
25
|
- SessionStart: session registration and the config snapshot (Codex sends session registration only).
|
|
25
26
|
- UserPromptSubmit: resends on the first prompt (Claude Code only).
|
|
27
|
+
- Stop: sends the work locations at the end of a turn in which they changed.
|
|
26
28
|
- A failing hook never stops the session. When the hook cannot send, it shows a one-line warning. Tell the user when a warning appears.
|
|
27
29
|
|
|
28
30
|
## Which skill and tool to use, and when
|
|
@@ -30,7 +32,7 @@ This session has the Harnessforce hook installed. Harnessforce links the work of
|
|
|
30
32
|
- For work on a specified Issue ID, or when the environment variable `HARNESSFORCE_ISSUE` is set, follow `record-run` and use `start_run`, `get_issue`, and `complete_run`. An agent that cannot load the skill calls the tools in the same order.
|
|
31
33
|
- Pass the value of the `harnessforce session_id:` line in the context to `start_run`. If there are several, use the last one. If the line is missing, do not guess the session ID, and do not call `start_run` or `complete_run`.
|
|
32
34
|
- Use `propose-improvements` for the analysis of `/harnessforce:tune`.
|
|
33
|
-
- When no Issue is specified, do not start recording a Run.
|
|
35
|
+
- When no Issue is specified, do not start recording a Run on your own. For work that commits changes, follow "When no Issue is specified" in `record-run` and ask the user once whether to link it to an existing Issue.
|
|
34
36
|
|
|
35
37
|
## Red Flags
|
|
36
38
|
|