claude-dev-env 8.98.6 → 8.98.8
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/.agents/skills/pr-lifecycle/SKILL.md +4 -4
- package/bin/install-pstack-plugin.mjs +71 -21
- package/bin/install-pstack-plugin.test.mjs +46 -9
- package/bin/install.test.mjs +9 -9
- package/docs/account-broker.md +1 -1
- package/hooks/blocking/reply_length_gate.py +11 -2
- package/hooks/blocking/resolve_thread_reply_gate.py +135 -0
- package/hooks/blocking/test_reply_length_gate.py +11 -0
- package/hooks/blocking/test_resolve_thread_reply_gate.py +199 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +3 -1
- package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +22 -0
- package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +12 -0
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
- package/hooks/session/artifact_template_pointer.py +26 -3
- package/hooks/session/test_artifact_template_pointer.py +16 -0
- package/hooks/test_transcript_skill_scan.py +30 -0
- package/hooks/transcript_skill_scan.py +72 -1
- package/package.json +1 -1
- package/scripts/Sync-RepoMain.ps1 +102 -4
- package/scripts/account_broker.py +12 -4
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +6 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +1 -0
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +35 -0
- package/scripts/test_durable_post_lint.py +176 -3
- package/scripts/tests/Sync-RepoMain.Tests.ps1 +43 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_sync_repo_main_mirror.py +226 -0
|
@@ -22,7 +22,7 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh`. Small
|
|
|
22
22
|
|
|
23
23
|
### Pull request submission rules
|
|
24
24
|
|
|
25
|
-
**Prove the change where it runs before opening a PR.** Read `~/.claude/docs/rule-guides/proof-before-pull-request.md` for the body and hook requirements. A `feat` PR also needs an "Eval" section and a session that invoked `/claude-api build-eval`; read `~/.claude/docs/rule-guides/features-start-with-an-eval.md` before you build the feature.
|
|
25
|
+
**Prove the change where it runs before opening a PR.** Read `~/.claude/docs/rule-guides/proof-before-pull-request.md` for the body and hook requirements. A `feat` PR also needs an "Eval" section and a session that invoked `/claude-api build-eval`; read `~/.claude/docs/rule-guides/features-start-with-an-eval.md` before you build the feature. A new workflow that runs only on `workflow_dispatch` cannot run until its file is on the default branch: before the PR, run its `run:` steps with a stub `gh` on PATH, name in the body the live run that waits on the merge, and dispatch it right after the merge.
|
|
26
26
|
|
|
27
27
|
**Open PRs ready for review; use `--draft` when the owner requests it.** Before you commit copied files or open a PR, list the open PRs that change a file whose path starts with yours, then read each hit, with `gh pr list --state open --limit 1000 --json number,files --jq '.[] | select(any(.files[]; .path | startswith("<path>"))) | .number'`, so you do not duplicate a peer's PR. Each pre-existing failure or remaining defect the PR body names links a tracked issue.
|
|
28
28
|
|
|
@@ -121,7 +121,7 @@ An image a PR needs as visual evidence is not an exception to that row. Upload i
|
|
|
121
121
|
1. Fetch every reviewer comment before making any fix.
|
|
122
122
|
2. Create a checklist in the session's task tool with one item per comment.
|
|
123
123
|
3. Fix systematically, marking each todo complete.
|
|
124
|
-
4. Reply to each comment inline.
|
|
124
|
+
4. Reply to each comment inline, read the reply back from the thread, and resolve the thread only after the reply shows there.
|
|
125
125
|
|
|
126
126
|
Repair only the reported findings. A finding that comes back on a second head escalates. Fix the source the reviewer reads, or change the gate that files the finding. Post no second reply with the same text, because a repeat finding shows the first reply changed nothing the reviewer sees.
|
|
127
127
|
|
|
@@ -405,7 +405,7 @@ Resolve the active managed root (`CLAUDE_CONFIG_DIR` when set, `~/.claude`
|
|
|
405
405
|
otherwise), then run `<managed-root>/scripts/durable_post_lint.py` before the
|
|
406
406
|
server write. Pass the matching action and body file. Use `pr-create`,
|
|
407
407
|
`pr-edit`, `pr-comment`, `pr-review`, `issue-create`, `issue-edit`,
|
|
408
|
-
`issue-comment`, or `github-mcp-
|
|
408
|
+
`issue-comment`, `github-mcp-post`, or `github-mcp-comment` for an MCP comment or reply.
|
|
409
409
|
|
|
410
410
|
Pass `--repository <owner>/<name>` for the repository the post targets. A
|
|
411
411
|
post may name a private organization only inside a repository that
|
|
@@ -440,7 +440,7 @@ For pull requests, use
|
|
|
440
440
|
`.agents/skills/pull-request/scripts/pull_request.py`. It passes
|
|
441
441
|
`--body-file` to `gh` after the action-aware linter succeeds. For issues and
|
|
442
442
|
GitHub MCP posts, run the linter directly with `issue-create`, `issue-edit`,
|
|
443
|
-
`issue-comment`, or `github-mcp-
|
|
443
|
+
`issue-comment`, `github-mcp-post`, or `github-mcp-comment` as the action.
|
|
444
444
|
|
|
445
445
|
### Paginated reads slurp before they filter
|
|
446
446
|
|
|
@@ -72,6 +72,7 @@ const HOST_DEFINITIONS = Object.freeze({
|
|
|
72
72
|
),
|
|
73
73
|
installVerb: 'install',
|
|
74
74
|
uninstallVerb: 'uninstall',
|
|
75
|
+
updateVerb: 'update',
|
|
75
76
|
}),
|
|
76
77
|
codex: Object.freeze({
|
|
77
78
|
executable: 'codex',
|
|
@@ -79,18 +80,23 @@ const HOST_DEFINITIONS = Object.freeze({
|
|
|
79
80
|
homeVariable: 'CODEX_HOME',
|
|
80
81
|
installVerb: 'add',
|
|
81
82
|
uninstallVerb: 'remove',
|
|
83
|
+
updateVerb: null,
|
|
82
84
|
}),
|
|
83
85
|
});
|
|
84
86
|
|
|
85
87
|
/**
|
|
86
88
|
* Read the marketplace and plugin commands one host installs a set of plugins with.
|
|
87
89
|
*
|
|
88
|
-
* Every plugin in the set comes from one marketplace.
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
90
|
+
* Every plugin in the set comes from one marketplace. A host with an update
|
|
91
|
+
* verb gets refresh commands: the marketplace add and plugin install leave an
|
|
92
|
+
* existing entry in place, and the update commands then pull the newest
|
|
93
|
+
* published version over it, so an installed plugin stays installed through
|
|
94
|
+
* the whole run. When any refresh command fails, or the host has no update
|
|
95
|
+
* verb, the removal commands run and take out each installed plugin, its
|
|
96
|
+
* cached versions, and the marketplace clone, and the install commands that
|
|
97
|
+
* follow fetch the newest published version into an empty slot. Removing a
|
|
98
|
+
* marketplace uninstalls each plugin it supplied, so the plan removes and adds
|
|
99
|
+
* that marketplace once for the whole set.
|
|
94
100
|
*
|
|
95
101
|
* Claude Code and Codex publish a marketplace under different plugin verbs, so
|
|
96
102
|
* the install verb belongs to the host and the marketplace and identifier
|
|
@@ -102,6 +108,7 @@ const HOST_DEFINITIONS = Object.freeze({
|
|
|
102
108
|
* @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
|
|
103
109
|
* @param {string} host A host every spec names, `claude` or `codex`.
|
|
104
110
|
* @returns {{executable: string, executableVariable: string, homeVariable: string,
|
|
111
|
+
* refreshCommands: ReadonlyArray<ReadonlyArray<string>>,
|
|
105
112
|
* removalCommands: ReadonlyArray<ReadonlyArray<string>>,
|
|
106
113
|
* commands: ReadonlyArray<ReadonlyArray<string>>}} The host's install plan.
|
|
107
114
|
*/
|
|
@@ -126,18 +133,28 @@ export function marketplacePluginsPlan(allSpecs, host) {
|
|
|
126
133
|
}
|
|
127
134
|
const allSparsePaths = [...new Set(allSpecs.flatMap(eachSpec => eachSpec.sparsePaths))];
|
|
128
135
|
const sparseArguments = allSparsePaths.length > 0 ? ['--sparse', ...allSparsePaths] : [];
|
|
136
|
+
const marketplaceAddCommand = ['plugin', 'marketplace', 'add', firstSpec.marketplaceRepository, ...sparseArguments];
|
|
137
|
+
const allPluginInstallCommands = allSpecs.map(
|
|
138
|
+
eachSpec => ['plugin', hostDefinition.installVerb, eachSpec.pluginIdentifier],
|
|
139
|
+
);
|
|
140
|
+
const refreshCommands = hostDefinition.updateVerb
|
|
141
|
+
? [
|
|
142
|
+
marketplaceAddCommand,
|
|
143
|
+
['plugin', 'marketplace', hostDefinition.updateVerb, firstSpec.marketplaceName],
|
|
144
|
+
...allPluginInstallCommands,
|
|
145
|
+
...allSpecs.map(eachSpec => ['plugin', hostDefinition.updateVerb, eachSpec.pluginIdentifier]),
|
|
146
|
+
]
|
|
147
|
+
: [];
|
|
129
148
|
return {
|
|
130
149
|
executable: hostDefinition.executable,
|
|
131
150
|
executableVariable: hostDefinition.executableVariable,
|
|
132
151
|
homeVariable: hostDefinition.homeVariable,
|
|
152
|
+
refreshCommands,
|
|
133
153
|
removalCommands: [
|
|
134
154
|
...allSpecs.map(eachSpec => ['plugin', hostDefinition.uninstallVerb, eachSpec.pluginIdentifier]),
|
|
135
155
|
['plugin', 'marketplace', 'remove', firstSpec.marketplaceName],
|
|
136
156
|
],
|
|
137
|
-
commands: [
|
|
138
|
-
['plugin', 'marketplace', 'add', firstSpec.marketplaceRepository, ...sparseArguments],
|
|
139
|
-
...allSpecs.map(eachSpec => ['plugin', hostDefinition.installVerb, eachSpec.pluginIdentifier]),
|
|
140
|
-
],
|
|
157
|
+
commands: [marketplaceAddCommand, ...allPluginInstallCommands],
|
|
141
158
|
};
|
|
142
159
|
}
|
|
143
160
|
|
|
@@ -280,18 +297,49 @@ function lockedFolderDetail(allSpecs, host, homeDirectory, errorText, findLocker
|
|
|
280
297
|
}).join('');
|
|
281
298
|
}
|
|
282
299
|
|
|
300
|
+
function refreshInPlace(plan, executable, commandEnvironment, runCommand) {
|
|
301
|
+
for (const commandArguments of plan.refreshCommands) {
|
|
302
|
+
const outcome = runCommand(executable, [...commandArguments], {
|
|
303
|
+
environment: commandEnvironment,
|
|
304
|
+
});
|
|
305
|
+
if (namesAnAbsentCommand(outcome)) return 'absent';
|
|
306
|
+
if (outcome.status !== 0 || outcome.error) return 'failed';
|
|
307
|
+
}
|
|
308
|
+
return 'refreshed';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function removeOrphanedVersionsForHost(allSpecs, host, homeDirectory) {
|
|
312
|
+
const hostDefinition = HOST_DEFINITIONS[host];
|
|
313
|
+
if (!hostDefinition.orphanedVersionsDirectory || !homeDirectory) return;
|
|
314
|
+
for (const eachSpec of allSpecs) {
|
|
315
|
+
removeOrphanedPluginVersions(hostDefinition.orphanedVersionsDirectory(homeDirectory, eachSpec));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
283
319
|
function installForHost(allSpecs, host, homeDirectory, environment, runCommand, findLockers) {
|
|
284
320
|
const plan = marketplacePluginsPlan(allSpecs, host);
|
|
285
321
|
const executable = environment[plan.executableVariable] || plan.executable;
|
|
286
322
|
const commandEnvironment = { [plan.homeVariable]: homeDirectory };
|
|
323
|
+
const staleCloneDirectory = HOST_DEFINITIONS[host].staleCloneDirectory;
|
|
324
|
+
if (staleCloneDirectory && homeDirectory) {
|
|
325
|
+
removeStaleMarketplaceClone(staleCloneDirectory(homeDirectory, allSpecs[0]));
|
|
326
|
+
}
|
|
327
|
+
if (plan.refreshCommands.length > 0) {
|
|
328
|
+
const refreshOutcome = refreshInPlace(plan, executable, commandEnvironment, runCommand);
|
|
329
|
+
if (refreshOutcome === 'absent') return absentHostOutcome(allSpecs, host, executable);
|
|
330
|
+
if (refreshOutcome === 'refreshed') {
|
|
331
|
+
removeOrphanedVersionsForHost(allSpecs, host, homeDirectory);
|
|
332
|
+
return { host, executable, status: 'installed', warning: null };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
287
335
|
for (const commandArguments of plan.removalCommands) {
|
|
288
336
|
const outcome = runCommand(executable, [...commandArguments], {
|
|
289
337
|
environment: commandEnvironment,
|
|
290
338
|
});
|
|
291
339
|
if (namesAnAbsentCommand(outcome)) return absentHostOutcome(allSpecs, host, executable);
|
|
292
340
|
}
|
|
293
|
-
if (
|
|
294
|
-
removeStaleMarketplaceClone(
|
|
341
|
+
if (staleCloneDirectory && homeDirectory) {
|
|
342
|
+
removeStaleMarketplaceClone(staleCloneDirectory(homeDirectory, allSpecs[0]));
|
|
295
343
|
}
|
|
296
344
|
for (const commandArguments of plan.commands) {
|
|
297
345
|
const outcome = runCommand(executable, [...commandArguments], {
|
|
@@ -309,12 +357,7 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
309
357
|
};
|
|
310
358
|
}
|
|
311
359
|
}
|
|
312
|
-
|
|
313
|
-
if (hostDefinition.orphanedVersionsDirectory && homeDirectory) {
|
|
314
|
-
for (const eachSpec of allSpecs) {
|
|
315
|
-
removeOrphanedPluginVersions(hostDefinition.orphanedVersionsDirectory(homeDirectory, eachSpec));
|
|
316
|
-
}
|
|
317
|
-
}
|
|
360
|
+
removeOrphanedVersionsForHost(allSpecs, host, homeDirectory);
|
|
318
361
|
return { host, executable, status: 'installed', warning: null };
|
|
319
362
|
}
|
|
320
363
|
|
|
@@ -338,10 +381,17 @@ export function restartNotice(allSpecs, allInstalledHosts) {
|
|
|
338
381
|
/**
|
|
339
382
|
* Install a set of plugins from one marketplace into each host's own home.
|
|
340
383
|
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
384
|
+
* Claude Code refreshes in place: it adds the marketplace and installs each
|
|
385
|
+
* plugin, both of which leave an existing entry alone, then updates the
|
|
386
|
+
* marketplace and each plugin. An installed plugin stays installed and enabled
|
|
387
|
+
* through the run, so a session that starts mid-run still loads it, and a
|
|
388
|
+
* failed network step leaves the prior version in place. When a refresh
|
|
389
|
+
* command fails, such as a marketplace add whose sparse paths changed, the
|
|
390
|
+
* host falls back to the clean install Codex always runs: it removes every
|
|
391
|
+
* plugin in the set and then the marketplace, so an older version never
|
|
392
|
+
* survives beside the new one, and adds them again. A removal that finds
|
|
343
393
|
* nothing to remove exits non-zero on a fresh home, and the install goes on.
|
|
344
|
-
* Before
|
|
394
|
+
* Before each Claude marketplace add, the install deletes the half-made clone
|
|
345
395
|
* folder an interrupted earlier add left, so the add starts in an empty slot.
|
|
346
396
|
*
|
|
347
397
|
* Each host is one member of the batch. A host without its command-line tool
|
|
@@ -68,10 +68,10 @@ test('each host installs into the managed root this run wrote', () => {
|
|
|
68
68
|
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, runner);
|
|
69
69
|
assert.equal(outcome.status, 'installed');
|
|
70
70
|
assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
|
|
71
|
-
['claude', 'plugin', 'uninstall', PSTACK_PLUGIN_IDENTIFIER],
|
|
72
|
-
['claude', 'plugin', 'marketplace', 'remove', 'pstack-claude'],
|
|
73
71
|
['claude', 'plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY],
|
|
72
|
+
['claude', 'plugin', 'marketplace', 'update', 'pstack-claude'],
|
|
74
73
|
['claude', 'plugin', 'install', PSTACK_PLUGIN_IDENTIFIER],
|
|
74
|
+
['claude', 'plugin', 'update', PSTACK_PLUGIN_IDENTIFIER],
|
|
75
75
|
['codex', 'plugin', 'remove', PSTACK_PLUGIN_IDENTIFIER],
|
|
76
76
|
['codex', 'plugin', 'marketplace', 'remove', 'pstack-claude'],
|
|
77
77
|
['codex', 'plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY],
|
|
@@ -81,7 +81,43 @@ test('each host installs into the managed root this run wrote', () => {
|
|
|
81
81
|
assert.equal(runner.calls[4].environment.CODEX_HOME, ROOTS.codexHome);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
test('
|
|
84
|
+
test('the Claude plan refreshes in place and Codex has no refresh commands', () => {
|
|
85
|
+
assert.deepEqual(marketplacePluginsPlan([PSTACK_PLUGIN_SPEC], 'claude').refreshCommands, [
|
|
86
|
+
['plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY],
|
|
87
|
+
['plugin', 'marketplace', 'update', 'pstack-claude'],
|
|
88
|
+
['plugin', 'install', PSTACK_PLUGIN_IDENTIFIER],
|
|
89
|
+
['plugin', 'update', PSTACK_PLUGIN_IDENTIFIER],
|
|
90
|
+
]);
|
|
91
|
+
assert.deepEqual(marketplacePluginsPlan([PSTACK_PLUGIN_SPEC], 'codex').refreshCommands, []);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a Claude refresh that passes never uninstalls the plugin or removes its marketplace', () => {
|
|
95
|
+
const runner = recordingRunner();
|
|
96
|
+
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], { ...ROOTS, hosts: ['claude'] }, runner);
|
|
97
|
+
assert.equal(outcome.status, 'installed');
|
|
98
|
+
const allRemovalCalls = runner.calls.filter(call => (
|
|
99
|
+
call.commandArguments.includes('uninstall') || call.commandArguments.includes('remove')
|
|
100
|
+
));
|
|
101
|
+
assert.deepEqual(allRemovalCalls, []);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a failed Claude refresh falls back to removing and adding the marketplace again', () => {
|
|
105
|
+
const runner = recordingRunner((executable, commandArguments) => (
|
|
106
|
+
commandArguments[2] === 'add' && runner.calls.length === 1
|
|
107
|
+
? { status: 1, stderr: "its source doesn't match its extraKnownMarketplaces entry" }
|
|
108
|
+
: { status: 0, stderr: '' }));
|
|
109
|
+
const outcome = installMarketplacePlugins([USAGE_WRAPUP_PLUGIN_SPEC], { ...ROOTS, hosts: ['claude'] }, runner);
|
|
110
|
+
assert.equal(outcome.status, 'installed');
|
|
111
|
+
assert.deepEqual(runner.calls.map(call => call.commandArguments), [
|
|
112
|
+
['plugin', 'marketplace', 'add', 'jl-cmd-projects/claude-dev-env', '--sparse', '.claude-plugin', 'packages/usage-wrapup'],
|
|
113
|
+
['plugin', 'uninstall', 'usage-wrapup@claude-dev-env'],
|
|
114
|
+
['plugin', 'marketplace', 'remove', 'claude-dev-env'],
|
|
115
|
+
['plugin', 'marketplace', 'add', 'jl-cmd-projects/claude-dev-env', '--sparse', '.claude-plugin', 'packages/usage-wrapup'],
|
|
116
|
+
['plugin', 'install', 'usage-wrapup@claude-dev-env'],
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('each host removes the installed plugin and marketplace before a clean install', () => {
|
|
85
121
|
assert.deepEqual(marketplacePluginsPlan([PSTACK_PLUGIN_SPEC], 'claude').removalCommands, [
|
|
86
122
|
['plugin', 'uninstall', PSTACK_PLUGIN_IDENTIFIER],
|
|
87
123
|
['plugin', 'marketplace', 'remove', 'pstack-claude'],
|
|
@@ -95,6 +131,7 @@ test('each host removes the installed plugin and marketplace before it installs'
|
|
|
95
131
|
test('a removal that finds nothing installed still lets the install run', () => {
|
|
96
132
|
const runner = recordingRunner((executable, commandArguments) => (
|
|
97
133
|
commandArguments.includes('uninstall') || commandArguments.includes('remove')
|
|
134
|
+
|| commandArguments.includes('update')
|
|
98
135
|
? { status: 1, stderr: 'not installed' }
|
|
99
136
|
: { status: 0, stderr: '' }));
|
|
100
137
|
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], { ...ROOTS, hosts: ['claude'] }, runner);
|
|
@@ -267,10 +304,10 @@ test('installing usage-wrapup runs only the Claude commands, inside the managed
|
|
|
267
304
|
assert.equal(outcome.status, 'installed');
|
|
268
305
|
assert.deepEqual(outcome.hosts.map(host => host.host), ['claude']);
|
|
269
306
|
assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
|
|
270
|
-
['claude', 'plugin', 'uninstall', 'usage-wrapup@claude-dev-env'],
|
|
271
|
-
['claude', 'plugin', 'marketplace', 'remove', 'claude-dev-env'],
|
|
272
307
|
['claude', 'plugin', 'marketplace', 'add', 'jl-cmd-projects/claude-dev-env', '--sparse', '.claude-plugin', 'packages/usage-wrapup'],
|
|
308
|
+
['claude', 'plugin', 'marketplace', 'update', 'claude-dev-env'],
|
|
273
309
|
['claude', 'plugin', 'install', 'usage-wrapup@claude-dev-env'],
|
|
310
|
+
['claude', 'plugin', 'update', 'usage-wrapup@claude-dev-env'],
|
|
274
311
|
]);
|
|
275
312
|
for (const call of runner.calls) {
|
|
276
313
|
assert.deepEqual(call.environment, { CLAUDE_CONFIG_DIR: ROOTS.claudeRoot });
|
|
@@ -319,21 +356,21 @@ for (const [spec, packageFolder] of [
|
|
|
319
356
|
});
|
|
320
357
|
}
|
|
321
358
|
|
|
322
|
-
test('should
|
|
359
|
+
test('should add and update the shared marketplace once when installing usage-wrapup and subagent-models together', () => {
|
|
323
360
|
const runner = recordingRunner();
|
|
324
361
|
const outcome = installMarketplacePlugins([USAGE_WRAPUP_PLUGIN_SPEC, SUBAGENT_MODELS_PLUGIN_SPEC], ROOTS, runner);
|
|
325
362
|
assert.equal(outcome.status, 'installed');
|
|
326
363
|
assert.deepEqual(outcome.hosts.map(host => host.host), ['claude']);
|
|
327
364
|
assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
|
|
328
|
-
['claude', 'plugin', 'uninstall', 'usage-wrapup@claude-dev-env'],
|
|
329
|
-
['claude', 'plugin', 'uninstall', 'subagent-models@claude-dev-env'],
|
|
330
|
-
['claude', 'plugin', 'marketplace', 'remove', 'claude-dev-env'],
|
|
331
365
|
[
|
|
332
366
|
'claude', 'plugin', 'marketplace', 'add', 'jl-cmd-projects/claude-dev-env',
|
|
333
367
|
'--sparse', '.claude-plugin', 'packages/usage-wrapup', 'packages/subagent-models',
|
|
334
368
|
],
|
|
369
|
+
['claude', 'plugin', 'marketplace', 'update', 'claude-dev-env'],
|
|
335
370
|
['claude', 'plugin', 'install', 'usage-wrapup@claude-dev-env'],
|
|
336
371
|
['claude', 'plugin', 'install', 'subagent-models@claude-dev-env'],
|
|
372
|
+
['claude', 'plugin', 'update', 'usage-wrapup@claude-dev-env'],
|
|
373
|
+
['claude', 'plugin', 'update', 'subagent-models@claude-dev-env'],
|
|
337
374
|
]);
|
|
338
375
|
});
|
|
339
376
|
|
package/bin/install.test.mjs
CHANGED
|
@@ -3148,10 +3148,10 @@ test('a full install adds the pstack marketplace and plugin on both hosts', t =>
|
|
|
3148
3148
|
const installerOutput = runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
|
|
3149
3149
|
|
|
3150
3150
|
assert.deepEqual(sandbox.recordedCommands(), [
|
|
3151
|
-
'claude plugin uninstall pstack@pstack-claude',
|
|
3152
|
-
'claude plugin marketplace remove pstack-claude',
|
|
3153
3151
|
'claude plugin marketplace add jl-cmd/pstack-claude',
|
|
3152
|
+
'claude plugin marketplace update pstack-claude',
|
|
3154
3153
|
'claude plugin install pstack@pstack-claude',
|
|
3154
|
+
'claude plugin update pstack@pstack-claude',
|
|
3155
3155
|
'codex plugin remove pstack@pstack-claude',
|
|
3156
3156
|
'codex plugin marketplace remove pstack-claude',
|
|
3157
3157
|
'codex plugin marketplace add jl-cmd/pstack-claude',
|
|
@@ -3190,10 +3190,10 @@ test('a full install adds DeepStack for Claude, Codex, and Cursor', t => {
|
|
|
3190
3190
|
});
|
|
3191
3191
|
|
|
3192
3192
|
assert.deepEqual(sandbox.recordedCommands(), [
|
|
3193
|
-
'claude plugin uninstall deepstack@DeepStack',
|
|
3194
|
-
'claude plugin marketplace remove DeepStack',
|
|
3195
3193
|
'claude plugin marketplace add jl-cmd-projects/DeepStack',
|
|
3194
|
+
'claude plugin marketplace update DeepStack',
|
|
3196
3195
|
'claude plugin install deepstack@DeepStack',
|
|
3196
|
+
'claude plugin update deepstack@DeepStack',
|
|
3197
3197
|
'codex plugin remove deepstack@DeepStack',
|
|
3198
3198
|
'codex plugin marketplace remove DeepStack',
|
|
3199
3199
|
'codex plugin marketplace add jl-cmd-projects/DeepStack',
|
|
@@ -3407,10 +3407,10 @@ test('a pstack install after a --no-pstack install seeds the Codex model sheet',
|
|
|
3407
3407
|
});
|
|
3408
3408
|
|
|
3409
3409
|
const USAGE_WRAPUP_CLAUDE_COMMANDS = Object.freeze([
|
|
3410
|
-
'claude plugin uninstall usage-wrapup@claude-dev-env',
|
|
3411
|
-
'claude plugin marketplace remove claude-dev-env',
|
|
3412
3410
|
'claude plugin marketplace add jl-cmd-projects/claude-dev-env --sparse .claude-plugin packages/usage-wrapup',
|
|
3411
|
+
'claude plugin marketplace update claude-dev-env',
|
|
3413
3412
|
'claude plugin install usage-wrapup@claude-dev-env',
|
|
3413
|
+
'claude plugin update usage-wrapup@claude-dev-env',
|
|
3414
3414
|
]);
|
|
3415
3415
|
|
|
3416
3416
|
function usageWrapupCommands(allRecordedCommands) {
|
|
@@ -3485,12 +3485,12 @@ test('the help output names the usage-wrapup opt-out beside the pstack one', ()
|
|
|
3485
3485
|
});
|
|
3486
3486
|
|
|
3487
3487
|
const REPOSITORY_PLUGIN_CLAUDE_COMMANDS = Object.freeze([
|
|
3488
|
-
'claude plugin uninstall usage-wrapup@claude-dev-env',
|
|
3489
|
-
'claude plugin uninstall subagent-models@claude-dev-env',
|
|
3490
|
-
'claude plugin marketplace remove claude-dev-env',
|
|
3491
3488
|
'claude plugin marketplace add jl-cmd-projects/claude-dev-env --sparse .claude-plugin packages/usage-wrapup packages/subagent-models',
|
|
3489
|
+
'claude plugin marketplace update claude-dev-env',
|
|
3492
3490
|
'claude plugin install usage-wrapup@claude-dev-env',
|
|
3493
3491
|
'claude plugin install subagent-models@claude-dev-env',
|
|
3492
|
+
'claude plugin update usage-wrapup@claude-dev-env',
|
|
3493
|
+
'claude plugin update subagent-models@claude-dev-env',
|
|
3494
3494
|
]);
|
|
3495
3495
|
|
|
3496
3496
|
test('should install subagent-models beside usage-wrapup with one marketplace add on a full install', t => {
|
package/docs/account-broker.md
CHANGED
|
@@ -18,7 +18,7 @@ python scripts/account_broker.py check --product codex
|
|
|
18
18
|
printf "%s" "prompt" | python scripts/account_broker.py run --product claude --report report.json -- claude --output-format json -p
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
`accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `profile`, `home`, `reason`, `tier`, and `resets_at`. `profile` is the name of the profile whose home the account uses, such as `editor` for a home at `~/.claude-profiles/editor`. It is null for the main account, a Codex account, a home outside the profiles root, a home whose folder name is not a valid profile name such as `main`, and a wait decision. `--spent` takes an account's name or its profile. The account order file matches the name, command, or launcher, and resume affinity stores the command, or the name for an account without one. When a mark is one account's name and another account's profile, the name wins. Every mark is read before the first one is saved, so an unreadable mark saves none. `accounts` and the `choose` account list carry the same `profile` field. A wait decision has no account or home and includes the soonest reset in UTC. An account counts as free at the latest reset of the windows that hold it, and one hour out when one of those windows has no reset time. A failed meter read reuses the account's last good reading when that reading is 30 minutes old or less, and otherwise excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. A `--spent` name absent from the roster is saved until the supplied Unix reset, or for one hour when no reset is given. A mark splits at its last colon, so an absent name that contains a colon, such as `C:\bin\old.cmd`, takes an explicit reset, such as `C:\bin\old.cmd:1800000000`. While any such mark is still in the future, `choose` waits until the soonest mark and exits 3, and with no Codex roster `--spent default` waits until that mark passes. Meter reads younger than 60 seconds are reused.
|
|
21
|
+
`accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `profile`, `home`, `reason`, `tier`, and `resets_at`. `profile` is the name of the profile whose home the account uses, such as `editor` for a home at `~/.claude-profiles/editor`. It is null for the main account, a Codex account, a home outside the profiles root, a home whose folder name is not a valid profile name such as `main`, and a wait decision. `--spent` takes an account's name or its profile. The account order file matches the name, command, or launcher, and resume affinity stores the command, or the name for an account without one. When a mark is one account's name and another account's profile, the name wins. Every mark is read before the first one is saved, so an unreadable mark saves none. `accounts` and the `choose` account list carry the same `profile` field. A wait decision has no account or home and includes the soonest reset in UTC. An account counts as free at the latest reset of the windows that hold it, and one hour out when one of those windows has no reset time. A failed meter read reuses the account's last good reading when that reading is 30 minutes old or less, and otherwise excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. A `--spent` name absent from the roster is saved until the supplied Unix reset, or for one hour when no reset is given. A mark splits at its last colon, so an absent name that contains a colon, such as `C:\bin\old.cmd`, takes an explicit reset, such as `C:\bin\old.cmd:1800000000`. While any such mark is still in the future, `choose` waits until the soonest mark and exits 3, and with no Codex roster `--spent default` waits until that mark passes. `default` is the name `choose` gives the Codex home when no roster is configured, so while a Codex roster exists `choose` neither saves a `--spent default` mark nor waits on a stored one. Meter reads younger than 60 seconds are reused.
|
|
22
22
|
|
|
23
23
|
`check` prints nothing and exits 3 while every account is below its floor. `run` sets `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. A Claude job also starts without the parent session's variables, such as `CLAUDE_CODE_SESSION_ID` and `CLAUDECODE`, so a child started from inside a Claude Code session runs as its own session. `ALL_PARENT_CLAUDE_SESSION_VARIABLES` lists them. It replays the same stdin bytes when a usage limit or start failure leads to another account. Send the prompt on stdin. When the command resolves to a `.cmd` or `.bat` file, the broker refuses an argument that holds a cmd.exe metacharacter and records `start_failed`. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
|
|
24
24
|
|
|
@@ -559,15 +559,24 @@ def gh_command_token_runs(all_command_tokens: list[str]) -> list[list[str]]:
|
|
|
559
559
|
return all_runs
|
|
560
560
|
|
|
561
561
|
|
|
562
|
+
def comment_length_violation(comment_text: str) -> str | None:
|
|
563
|
+
"""Return the deny reason for one over-long GitHub comment text, or None when it fits.
|
|
564
|
+
|
|
565
|
+
Fenced code blocks and collapsed ``<details>`` blocks carry no words.
|
|
566
|
+
"""
|
|
567
|
+
violation = length_violation(DETAILS_BLOCK_PATTERN.sub(" ", comment_text))
|
|
568
|
+
return None if violation is None else violation + COMMENT_RETRY_INSTRUCTION
|
|
569
|
+
|
|
570
|
+
|
|
562
571
|
def github_comment_violation(tool_name: object, all_tool_input: dict[str, object]) -> tuple[str, str] | None:
|
|
563
572
|
"""Return the deny reason and text for an over-long GitHub comment, or None.
|
|
564
573
|
|
|
565
574
|
Fenced code blocks and collapsed ``<details>`` blocks carry no words.
|
|
566
575
|
"""
|
|
567
576
|
for each_comment_text in github_comment_texts(tool_name, all_tool_input):
|
|
568
|
-
violation =
|
|
577
|
+
violation = comment_length_violation(each_comment_text)
|
|
569
578
|
if violation is not None:
|
|
570
|
-
return violation
|
|
579
|
+
return violation, each_comment_text
|
|
571
580
|
return None
|
|
572
581
|
|
|
573
582
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Deny a review thread resolve until a reply posted after the last user prompt and the last resolve."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
10
|
+
if hooks_directory not in sys.path:
|
|
11
|
+
sys.path.insert(0, hooks_directory)
|
|
12
|
+
|
|
13
|
+
from blocking.pr_lifecycle_skill_gate import deny_decision, transcript_paths
|
|
14
|
+
from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
|
|
15
|
+
from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
|
|
16
|
+
from hooks_constants.resolve_thread_reply_gate_constants import (
|
|
17
|
+
ALL_REPLY_TOOL_NAME_SUFFIXES,
|
|
18
|
+
MISSING_REPLY_REASON,
|
|
19
|
+
RESOLVE_TOOL_NAME_PATTERN,
|
|
20
|
+
SHELL_COMMAND_KEY,
|
|
21
|
+
SHELL_REPLY_COMMAND_PATTERN,
|
|
22
|
+
)
|
|
23
|
+
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
24
|
+
from transcript_skill_scan import TranscriptToolCall, tool_calls_since_last_prompt
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_resolve_call(tool_name: object) -> bool:
|
|
28
|
+
"""Return whether a tool name resolves a review thread, such as ``mcp__github__resolve_review_thread``.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
tool_name: The tool name from a hook payload or transcript.
|
|
32
|
+
"""
|
|
33
|
+
return isinstance(tool_name, str) and RESOLVE_TOOL_NAME_PATTERN.match(tool_name) is not None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_reply_call(tool_call: TranscriptToolCall) -> bool:
|
|
37
|
+
"""Return whether a transcript tool call replies on a review thread.
|
|
38
|
+
|
|
39
|
+
::
|
|
40
|
+
|
|
41
|
+
mcp__github__add_reply_to_pull_request_comment -> True
|
|
42
|
+
Bash "gh api repos/o/r/pulls/7/comments/99/replies -f body=x" -> True
|
|
43
|
+
Bash "gh api graphql" with addPullRequestReviewThreadReply -> True
|
|
44
|
+
mcp__github__add_issue_comment -> False
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
tool_call: One tool call read from the transcript.
|
|
48
|
+
"""
|
|
49
|
+
if tool_call.name.endswith(ALL_REPLY_TOOL_NAME_SUFFIXES):
|
|
50
|
+
return True
|
|
51
|
+
command = tool_call.tool_input.get(SHELL_COMMAND_KEY)
|
|
52
|
+
return (
|
|
53
|
+
tool_call.name in SHELL_TOOL_NAMES
|
|
54
|
+
and isinstance(command, str)
|
|
55
|
+
and SHELL_REPLY_COMMAND_PATTERN.search(command) is not None
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def has_posted_reply_since_last_resolve(all_transcript_lines: Iterable[str]) -> bool:
|
|
60
|
+
"""Return whether a reply that succeeded since the last user prompt is not yet spent on a successful resolve.
|
|
61
|
+
|
|
62
|
+
::
|
|
63
|
+
|
|
64
|
+
prompt, reply ok, resolve pending -> True
|
|
65
|
+
prompt, reply refused by a hook, resolve -> False
|
|
66
|
+
prompt, resolve -> False
|
|
67
|
+
prompt, reply ok, resolve ok, resolve pending -> False
|
|
68
|
+
prompt, reply ok, reply ok, resolve ok, resolve pending -> True
|
|
69
|
+
|
|
70
|
+
Each successful resolve spends one successful reply. A failed or denied
|
|
71
|
+
resolve spends none, so a retry after a resolve error needs no second reply.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
all_transcript_lines: JSON transcript entries, one per line.
|
|
75
|
+
"""
|
|
76
|
+
unspent_reply_count = 0
|
|
77
|
+
for each_call in tool_calls_since_last_prompt(all_transcript_lines):
|
|
78
|
+
unspent_reply_count = _unspent_reply_count_after(unspent_reply_count, each_call)
|
|
79
|
+
return unspent_reply_count > 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _unspent_reply_count_after(unspent_reply_count: int, tool_call: TranscriptToolCall) -> int:
|
|
83
|
+
if not tool_call.succeeded:
|
|
84
|
+
return unspent_reply_count
|
|
85
|
+
if is_resolve_call(tool_call.name):
|
|
86
|
+
return max(0, unspent_reply_count - 1)
|
|
87
|
+
if is_reply_call(tool_call):
|
|
88
|
+
return unspent_reply_count + 1
|
|
89
|
+
return unspent_reply_count
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _transcript_shows_reply(transcript_path: str) -> bool | None:
|
|
93
|
+
try:
|
|
94
|
+
with open(transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
|
|
95
|
+
return has_posted_reply_since_last_resolve(transcript)
|
|
96
|
+
except OSError:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
101
|
+
"""Return a deny for a review thread resolve with no posted reply before it, else None.
|
|
102
|
+
|
|
103
|
+
::
|
|
104
|
+
|
|
105
|
+
resolve after a reply that posted -> None
|
|
106
|
+
resolve after a reply a hook refused -> MISSING_REPLY_REASON
|
|
107
|
+
resolve with no reply this turn -> MISSING_REPLY_REASON
|
|
108
|
+
second resolve after one reply -> MISSING_REPLY_REASON
|
|
109
|
+
resolve with an unreadable transcript -> None
|
|
110
|
+
any other tool -> None
|
|
111
|
+
|
|
112
|
+
The gate reads the calling agent's own transcript: a subagent's file when
|
|
113
|
+
the payload names one, else the session transcript.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
117
|
+
"""
|
|
118
|
+
if not is_resolve_call(all_payload_fields.get("tool_name")):
|
|
119
|
+
return None
|
|
120
|
+
all_paths = transcript_paths(all_payload_fields)
|
|
121
|
+
if not all_paths:
|
|
122
|
+
return None
|
|
123
|
+
shows_reply = _transcript_shows_reply(all_paths[-1])
|
|
124
|
+
if shows_reply is None or shows_reply:
|
|
125
|
+
return None
|
|
126
|
+
return deny_decision(MISSING_REPLY_REASON)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main() -> int:
|
|
130
|
+
"""Read one payload, print a deny when needed, and exit zero."""
|
|
131
|
+
return print_decision_from_stdin(decision_for)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
sys.exit(main())
|
|
@@ -1065,3 +1065,14 @@ def test_should_read_gh_comment_body_and_skip_pr_create() -> None:
|
|
|
1065
1065
|
assert reply_length_gate.github_comment_texts("Bash", {"command": flagged_command}) == [f"note=x. {LONG_GITHUB_COMMENT}"]
|
|
1066
1066
|
chained_command = f"git push && {comment_command}"
|
|
1067
1067
|
assert reply_length_gate.github_comment_texts("Bash", {"command": chained_command}) == [LONG_GITHUB_COMMENT]
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def test_should_report_an_over_long_comment_text_with_the_comment_instruction() -> None:
|
|
1071
|
+
violation = reply_length_gate.comment_length_violation(LONG_GITHUB_COMMENT)
|
|
1072
|
+
assert violation is not None
|
|
1073
|
+
assert violation.endswith(reply_length_gate.COMMENT_RETRY_INSTRUCTION)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def test_should_pass_a_comment_text_whose_long_part_sits_in_a_details_block() -> None:
|
|
1077
|
+
comment_text = "Fixed.\n<details><summary>Log</summary>\n" + LONG_GITHUB_COMMENT + "\n</details>"
|
|
1078
|
+
assert reply_length_gate.comment_length_violation(comment_text) is None
|