claude-dev-env 8.31.1 → 8.32.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/.agents/skills/pull-request/SKILL.md +4 -3
- package/.agents/skills/pull-request/reference/publication-tasks.md +1 -1
- package/.agents/skills/pull-request/scripts/pull_request.py +5 -2
- package/.agents/skills/pull-request/scripts/test_pull_request.py +32 -0
- package/bin/codex-skill-load-block.mjs +87 -34
- package/bin/codex-skill-load-block.test.mjs +112 -4
- package/bin/install.mjs +23 -9
- package/bin/install.question-presentation.test.mjs +2 -2
- package/bin/install.test.mjs +105 -0
- package/bin/seed-codex-pstack-models.mjs +2 -2
- package/bin/seed-codex-pstack-models.test.mjs +52 -1
- package/commands/fix-followups.md +3 -3
- package/commands/sr-loop.md +4 -3
- package/hooks/advisory/test_pr_done_reminder.py +2 -1
- package/hooks/blocking/block_main_commit.py +2 -10
- package/hooks/blocking/test_block_main_commit.py +13 -0
- package/hooks/hooks_constants/block_main_commit_constants.py +9 -0
- package/hooks/hooks_constants/pr_done_reminder_constants.py +1 -1
- package/hooks/pyproject.toml +0 -1
- package/hooks/validators/README.md +0 -1
- package/hooks/validators/health_check.py +0 -1
- package/hooks/validators/run_all_validators.py +0 -13
- package/hooks/validators/test_run_all_validators.py +5 -36
- package/package.json +1 -1
- package/rules/agent-merges-its-own-green-pull-request.md +1 -1
- package/rules/flag-non-breaking-findings.md +1 -1
- package/rules/git-workflow.md +2 -7
- package/rules/review-closure-is-a-check.md +1 -1
- package/scripts/dev_env_scripts_constants/followup_constants.py +1 -1
- package/scripts/test_followup_cli.py +2 -1
- package/hooks/validators/git_checks.py +0 -110
- package/hooks/validators/test_git_checks.py +0 -112
package/bin/install.test.mjs
CHANGED
|
@@ -54,6 +54,8 @@ import {
|
|
|
54
54
|
} from './install.mjs';
|
|
55
55
|
import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
|
|
56
56
|
import {
|
|
57
|
+
PACKAGE_GUIDANCE_BLOCK_END,
|
|
58
|
+
PACKAGE_GUIDANCE_BLOCK_START,
|
|
57
59
|
SKILL_LOAD_BLOCK_START,
|
|
58
60
|
SKILL_LOAD_INSTRUCTION,
|
|
59
61
|
QUESTION_PRESENTATION_BLOCK_START,
|
|
@@ -3162,6 +3164,109 @@ test('--no-pstack skips the plugin and delivers Codex question guidance', t => {
|
|
|
3162
3164
|
);
|
|
3163
3165
|
});
|
|
3164
3166
|
|
|
3167
|
+
const PSTACK_TEST_PACKAGE_GUIDANCE = readFileSync(
|
|
3168
|
+
join(dirname(fileURLToPath(import.meta.url)), '..', 'AGENTS.md'),
|
|
3169
|
+
'utf8',
|
|
3170
|
+
);
|
|
3171
|
+
|
|
3172
|
+
function assertHoldsPackageGuidance(codexGuidance) {
|
|
3173
|
+
assert.ok(codexGuidance.includes(`${PACKAGE_GUIDANCE_BLOCK_START}\n${PSTACK_TEST_PACKAGE_GUIDANCE}`));
|
|
3174
|
+
assert.ok(codexGuidance.includes(PACKAGE_GUIDANCE_BLOCK_END));
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
test('a fresh full install writes the package guidance into Codex AGENTS.md', t => {
|
|
3178
|
+
const sandbox = pstackPluginSandbox(t);
|
|
3179
|
+
|
|
3180
|
+
runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
|
|
3181
|
+
|
|
3182
|
+
const codexGuidance = readFileSync(join(sandbox.homeDirectory, '.codex', 'AGENTS.md'), 'utf8');
|
|
3183
|
+
assertHoldsPackageGuidance(codexGuidance);
|
|
3184
|
+
assert.ok(codexGuidance.startsWith(`${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n`));
|
|
3185
|
+
assert.ok(codexGuidance.includes(QUESTION_PRESENTATION_BLOCK_START));
|
|
3186
|
+
});
|
|
3187
|
+
|
|
3188
|
+
test('--no-pstack writes package and question guidance without the skill-load block', t => {
|
|
3189
|
+
const sandbox = pstackPluginSandbox(t);
|
|
3190
|
+
|
|
3191
|
+
runPstackInstaller(sandbox.homeDirectory, ['--no-pstack'], sandbox.environment);
|
|
3192
|
+
|
|
3193
|
+
assert.deepEqual(
|
|
3194
|
+
sandbox.recordedCommands().filter(eachCommand => eachCommand.startsWith('codex plugin')),
|
|
3195
|
+
[],
|
|
3196
|
+
);
|
|
3197
|
+
const codexGuidance = readFileSync(join(sandbox.homeDirectory, '.codex', 'AGENTS.md'), 'utf8');
|
|
3198
|
+
assertHoldsPackageGuidance(codexGuidance);
|
|
3199
|
+
assert.ok(codexGuidance.includes(QUESTION_PRESENTATION_BLOCK_START));
|
|
3200
|
+
assert.equal(codexGuidance.includes(SKILL_LOAD_BLOCK_START), false);
|
|
3201
|
+
});
|
|
3202
|
+
|
|
3203
|
+
test('--no-pstack replaces an outdated package block and keeps the text around it', t => {
|
|
3204
|
+
const sandbox = pstackPluginSandbox(t);
|
|
3205
|
+
const codexHome = join(sandbox.homeDirectory, '.codex');
|
|
3206
|
+
mkdirSync(codexHome, { recursive: true });
|
|
3207
|
+
writeFileSync(
|
|
3208
|
+
join(codexHome, 'AGENTS.md'),
|
|
3209
|
+
`My notes\n${PACKAGE_GUIDANCE_BLOCK_START}\nOld guidance\n${PACKAGE_GUIDANCE_BLOCK_END}\nMore notes\n`,
|
|
3210
|
+
);
|
|
3211
|
+
|
|
3212
|
+
runPstackInstaller(sandbox.homeDirectory, ['--no-pstack'], sandbox.environment);
|
|
3213
|
+
|
|
3214
|
+
const codexGuidance = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
|
|
3215
|
+
assert.ok(codexGuidance.startsWith(`My notes\n${PACKAGE_GUIDANCE_BLOCK_START}\n${PSTACK_TEST_PACKAGE_GUIDANCE}`));
|
|
3216
|
+
assert.ok(codexGuidance.includes(`${PACKAGE_GUIDANCE_BLOCK_END}\nMore notes\n`));
|
|
3217
|
+
assert.equal(codexGuidance.includes('Old guidance'), false);
|
|
3218
|
+
assert.equal(codexGuidance.includes(SKILL_LOAD_BLOCK_START), false);
|
|
3219
|
+
});
|
|
3220
|
+
|
|
3221
|
+
for (const linkTargetDirectory of ['.claude', '.agents']) {
|
|
3222
|
+
test(`--no-pstack turns a Codex link to the ${linkTargetDirectory} package guidance into a file`, t => {
|
|
3223
|
+
const sandbox = pstackPluginSandbox(t);
|
|
3224
|
+
const codexHome = join(sandbox.homeDirectory, '.codex');
|
|
3225
|
+
const codexAgentsPath = join(codexHome, 'AGENTS.md');
|
|
3226
|
+
mkdirSync(codexHome, { recursive: true });
|
|
3227
|
+
symlinkSync(join(sandbox.homeDirectory, linkTargetDirectory, 'AGENTS.md'), codexAgentsPath);
|
|
3228
|
+
|
|
3229
|
+
runPstackInstaller(sandbox.homeDirectory, ['--no-pstack'], sandbox.environment);
|
|
3230
|
+
|
|
3231
|
+
assert.equal(lstatSync(codexAgentsPath).isSymbolicLink(), false);
|
|
3232
|
+
const codexGuidance = readFileSync(codexAgentsPath, 'utf8');
|
|
3233
|
+
assertHoldsPackageGuidance(codexGuidance);
|
|
3234
|
+
assert.ok(codexGuidance.includes(QUESTION_PRESENTATION_BLOCK_START));
|
|
3235
|
+
assert.equal(codexGuidance.includes(SKILL_LOAD_BLOCK_START), false);
|
|
3236
|
+
});
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
test('--no-pstack leaves a Codex link to a file the package does not own unchanged', t => {
|
|
3240
|
+
const sandbox = pstackPluginSandbox(t);
|
|
3241
|
+
const codexHome = join(sandbox.homeDirectory, '.codex');
|
|
3242
|
+
const codexAgentsPath = join(codexHome, 'AGENTS.md');
|
|
3243
|
+
const ownGuidancePath = join(sandbox.homeDirectory, 'notes', 'codex.md');
|
|
3244
|
+
mkdirSync(codexHome, { recursive: true });
|
|
3245
|
+
mkdirSync(dirname(ownGuidancePath), { recursive: true });
|
|
3246
|
+
writeFileSync(ownGuidancePath, 'My own Codex notes\n');
|
|
3247
|
+
symlinkSync(ownGuidancePath, codexAgentsPath);
|
|
3248
|
+
|
|
3249
|
+
runPstackInstaller(sandbox.homeDirectory, ['--no-pstack'], sandbox.environment);
|
|
3250
|
+
|
|
3251
|
+
assert.equal(lstatSync(codexAgentsPath).isSymbolicLink(), true);
|
|
3252
|
+
assert.equal(readFileSync(ownGuidancePath, 'utf8'), 'My own Codex notes\n');
|
|
3253
|
+
});
|
|
3254
|
+
|
|
3255
|
+
test('a pstack install after a --no-pstack install seeds the Codex model sheet', t => {
|
|
3256
|
+
const sandbox = pstackPluginSandbox(t);
|
|
3257
|
+
const codexHome = join(sandbox.homeDirectory, '.codex');
|
|
3258
|
+
runPstackInstaller(sandbox.homeDirectory, ['--no-pstack'], sandbox.environment);
|
|
3259
|
+
|
|
3260
|
+
runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
|
|
3261
|
+
|
|
3262
|
+
assert.match(readFileSync(join(codexHome, 'pstack-models.md'), 'utf8'), /^session hook: on$/m);
|
|
3263
|
+
const codexGuidance = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
|
|
3264
|
+
assert.match(codexGuidance, /^feature, refactoring: gpt-6-sol$/m);
|
|
3265
|
+
assert.ok(codexGuidance.startsWith(`${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n`));
|
|
3266
|
+
assertHoldsPackageGuidance(codexGuidance);
|
|
3267
|
+
assert.ok(codexGuidance.includes(QUESTION_PRESENTATION_BLOCK_START));
|
|
3268
|
+
});
|
|
3269
|
+
|
|
3165
3270
|
function continuityCommandCount(configurationPath) {
|
|
3166
3271
|
if (!existsSync(configurationPath)) return 0;
|
|
3167
3272
|
const configuration = JSON.parse(readFileSync(configurationPath, 'utf8'));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { copyFileSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, constants as filesystemConstants } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import {
|
|
4
|
+
import { hasOnlyPackageManagedBlocks } from './codex-skill-load-block.mjs';
|
|
5
5
|
|
|
6
6
|
const CODEX_PRESET_PATH = fileURLToPath(new URL('../pstack-codex-models.md', import.meta.url));
|
|
7
7
|
|
|
@@ -16,7 +16,7 @@ export function seedCodexPstackModels(codexHome, writeAgentGuidance = writeFileS
|
|
|
16
16
|
const agentsEntry = lstatSync(agentsPath, { throwIfNoEntry: false });
|
|
17
17
|
if (agentsEntry && !agentsEntry.isFile()) return null;
|
|
18
18
|
const existingGuidance = agentsEntry ? readFileSync(agentsPath, 'utf8') : '';
|
|
19
|
-
if (agentsEntry && !
|
|
19
|
+
if (agentsEntry && !hasOnlyPackageManagedBlocks(existingGuidance)) return null;
|
|
20
20
|
|
|
21
21
|
const preset = readFileSync(CODEX_PRESET_PATH, 'utf8');
|
|
22
22
|
const agentGuidance = preset.replace(/^session hook: on\n?$/m, '');
|
|
@@ -4,7 +4,14 @@ import { existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, rmSync, sy
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { seedCodexPstackModels } from './seed-codex-pstack-models.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
PACKAGE_GUIDANCE_BLOCK_END,
|
|
9
|
+
PACKAGE_GUIDANCE_BLOCK_START,
|
|
10
|
+
QUESTION_PRESENTATION_BLOCK_END,
|
|
11
|
+
QUESTION_PRESENTATION_BLOCK_START,
|
|
12
|
+
SKILL_LOAD_BLOCK_END,
|
|
13
|
+
SKILL_LOAD_BLOCK_START,
|
|
14
|
+
} from './codex-skill-load-block.mjs';
|
|
8
15
|
|
|
9
16
|
const QUESTION_GUIDANCE = `${QUESTION_PRESENTATION_BLOCK_START}\nAsk one question.\n${QUESTION_PRESENTATION_BLOCK_END}\n`;
|
|
10
17
|
|
|
@@ -114,3 +121,47 @@ test('a linked question-only Codex file does not trigger model seeding', (contex
|
|
|
114
121
|
assert.equal(lstatSync(join(codexHome, 'AGENTS.md')).isSymbolicLink(), true);
|
|
115
122
|
assert.equal(readFileSync(linkedGuidancePath, 'utf8'), QUESTION_GUIDANCE);
|
|
116
123
|
});
|
|
124
|
+
|
|
125
|
+
const PACKAGE_GUIDANCE = `${PACKAGE_GUIDANCE_BLOCK_START}\nStatus\n${PACKAGE_GUIDANCE_BLOCK_END}\n`;
|
|
126
|
+
const SKILL_LOAD_GUIDANCE = `${SKILL_LOAD_BLOCK_START}\nLoad the skill.\n${SKILL_LOAD_BLOCK_END}\n`;
|
|
127
|
+
|
|
128
|
+
function codexHomeHolding(context, existingGuidance) {
|
|
129
|
+
const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
|
|
130
|
+
context.after(() => rmSync(root, { recursive: true, force: true }));
|
|
131
|
+
const codexHome = join(root, '.codex');
|
|
132
|
+
mkdirSync(codexHome);
|
|
133
|
+
writeFileSync(join(codexHome, 'AGENTS.md'), existingGuidance);
|
|
134
|
+
return codexHome;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const [label, existingGuidance] of [
|
|
138
|
+
['package block', PACKAGE_GUIDANCE],
|
|
139
|
+
['package then question blocks', `${PACKAGE_GUIDANCE}\n${QUESTION_GUIDANCE}`],
|
|
140
|
+
['question then package blocks', `${QUESTION_GUIDANCE}\n${PACKAGE_GUIDANCE}`],
|
|
141
|
+
['all three managed blocks', `${SKILL_LOAD_GUIDANCE}\n${PACKAGE_GUIDANCE}\n${QUESTION_GUIDANCE}`],
|
|
142
|
+
]) {
|
|
143
|
+
test(`a Codex file holding only the ${label} receives model defaults and keeps its blocks`, (context) => {
|
|
144
|
+
const codexHome = codexHomeHolding(context, existingGuidance);
|
|
145
|
+
|
|
146
|
+
const paths = seedCodexPstackModels(codexHome);
|
|
147
|
+
|
|
148
|
+
assert.deepEqual(paths, [join(codexHome, 'pstack-models.md'), join(codexHome, 'AGENTS.md')]);
|
|
149
|
+
const agents = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
|
|
150
|
+
assert.match(agents, /^feature, refactoring: gpt-6-sol$/m);
|
|
151
|
+
assert.ok(agents.endsWith(existingGuidance));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const [label, existingGuidance] of [
|
|
156
|
+
['an empty file', ''],
|
|
157
|
+
['a package block with personal notes', `${PACKAGE_GUIDANCE}My notes\n`],
|
|
158
|
+
['personal notes before managed blocks', `My notes\n${PACKAGE_GUIDANCE}\n${QUESTION_GUIDANCE}`],
|
|
159
|
+
]) {
|
|
160
|
+
test(`${label} does not trigger model seeding`, (context) => {
|
|
161
|
+
const codexHome = codexHomeHolding(context, existingGuidance);
|
|
162
|
+
|
|
163
|
+
assert.equal(seedCodexPstackModels(codexHome), null);
|
|
164
|
+
assert.equal(existsSync(join(codexHome, 'pstack-models.md')), false);
|
|
165
|
+
assert.equal(readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'), existingGuidance);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Fix every non-breaking finding the follow-up ledger holds and open a
|
|
2
|
+
description: Fix every non-breaking finding the follow-up ledger holds and open a PR for it
|
|
3
3
|
argument-hint: [repository root, or blank for the current repository]
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Fix the non-breaking findings a local gate recorded rather than blocked on,
|
|
7
|
-
and deliver them as one
|
|
7
|
+
and deliver them as one pull request.
|
|
8
8
|
|
|
9
9
|
The repository root is `$ARGUMENTS`, or the current repository when that is
|
|
10
10
|
blank.
|
|
@@ -50,7 +50,7 @@ finding names a generated file, regenerate it with the repository's tooling.
|
|
|
50
50
|
Branch from the repository's default branch. Commit each group on its own,
|
|
51
51
|
with a message naming the check identifier and the files.
|
|
52
52
|
|
|
53
|
-
Open the pull request
|
|
53
|
+
Open the pull request ready for review. Write the body as:
|
|
54
54
|
|
|
55
55
|
- One `Before:` paragraph saying which findings stood open.
|
|
56
56
|
- One `After:` paragraph saying what the tree now holds.
|
package/commands/sr-loop.md
CHANGED
|
@@ -39,7 +39,7 @@ stack's design, intent, and changes.
|
|
|
39
39
|
code, not the full repo suite), commit once (`git commit -F <file>`, body
|
|
40
40
|
written with the Write tool, Co-Authored-By line, 10-minute timeout — the
|
|
41
41
|
pre-commit gate runs its own tests), and push to the PR head branch. The PR
|
|
42
|
-
|
|
42
|
+
keeps its draft or ready state.
|
|
43
43
|
3. Repeat the invocation only after substantive findings or a user request.
|
|
44
44
|
**Skips are sticky:** carry every adjudicated skip
|
|
45
45
|
forward into the next pass as "already adjudicated — do not re-report: ..."
|
|
@@ -64,8 +64,9 @@ stack's design, intent, and changes.
|
|
|
64
64
|
|
|
65
65
|
## Finish
|
|
66
66
|
|
|
67
|
-
When both phases are clean, or only fixed nits remain,
|
|
68
|
-
with `gh pr ready`. Confirm that it
|
|
67
|
+
When both phases are clean, or only fixed nits remain, a target PR that is a
|
|
68
|
+
draft goes ready with `gh pr ready`. Confirm that it now reads ready. A
|
|
69
|
+
ready target PR stays as it is.
|
|
69
70
|
|
|
70
71
|
Report: passes run per phase, commits pushed with hashes, fixes applied, and
|
|
71
72
|
the standing skip list with reasons.
|
|
@@ -242,7 +242,8 @@ def test_main_should_remind_to_open_a_pr_when_the_branch_has_none(
|
|
|
242
242
|
context = _additional_context(_run_main(monkeypatch, capsys, _payload("git push"), no_pr))
|
|
243
243
|
|
|
244
244
|
assert "No open pull request found" in context
|
|
245
|
-
assert "gh pr create
|
|
245
|
+
assert "Open a pull request with gh pr create." in context
|
|
246
|
+
assert "--draft" not in context
|
|
246
247
|
|
|
247
248
|
|
|
248
249
|
def test_main_should_stay_quiet_on_any_other_gh_failure(
|
|
@@ -23,6 +23,7 @@ if _hooks_dir not in sys.path:
|
|
|
23
23
|
|
|
24
24
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
25
25
|
|
|
26
|
+
from hooks_constants.block_main_commit_constants import PULL_REQUEST_INSTRUCTION
|
|
26
27
|
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
27
28
|
|
|
28
29
|
GIT_COMMAND_TIMEOUT_SECONDS = 5
|
|
@@ -164,20 +165,11 @@ def parse_bash_command_from_stdin() -> str:
|
|
|
164
165
|
return bash_command
|
|
165
166
|
|
|
166
167
|
|
|
167
|
-
DRAFT_PR_INSTRUCTION = (
|
|
168
|
-
" Instead: (1) create a feature branch with `git checkout -b <descriptive-branch-name>`, "
|
|
169
|
-
"(2) commit your changes there, "
|
|
170
|
-
"(3) push with `git push -u origin <branch-name>`, "
|
|
171
|
-
"(4) create a draft PR with `gh pr create --draft`. "
|
|
172
|
-
"If you must commit to main, the user needs to approve explicitly."
|
|
173
|
-
)
|
|
174
|
-
|
|
175
|
-
|
|
176
168
|
def build_denial_response(branch_name: str, repo_dir: str | None) -> dict:
|
|
177
169
|
location = f" in {repo_dir}" if repo_dir else ""
|
|
178
170
|
denial_reason = (
|
|
179
171
|
f"BLOCKED: Direct commit to '{branch_name}'{location} is not allowed."
|
|
180
|
-
+
|
|
172
|
+
+ PULL_REQUEST_INSTRUCTION
|
|
181
173
|
)
|
|
182
174
|
|
|
183
175
|
return {
|
|
@@ -74,6 +74,19 @@ def test_blocks_commit_on_protected_branch_from_hook_event_cwd(tmp_path: Path) -
|
|
|
74
74
|
assert str(repository) in hook_response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def test_denial_should_open_a_ready_pull_request(tmp_path: Path) -> None:
|
|
78
|
+
repository = _create_repository(tmp_path, "main")
|
|
79
|
+
process_repository = _create_repository(tmp_path, "agent-owned-change")
|
|
80
|
+
|
|
81
|
+
completed_process = _run_commit_gate(repository, process_repository)
|
|
82
|
+
|
|
83
|
+
denial_reason = json.loads(completed_process.stdout)["hookSpecificOutput"][
|
|
84
|
+
"permissionDecisionReason"
|
|
85
|
+
]
|
|
86
|
+
assert "open a pull request with `gh pr create`" in denial_reason
|
|
87
|
+
assert "--draft" not in denial_reason
|
|
88
|
+
|
|
89
|
+
|
|
77
90
|
def test_allows_commit_on_owned_branch_from_hook_event_cwd(tmp_path: Path) -> None:
|
|
78
91
|
repository = _create_repository(tmp_path, "agent-owned-change")
|
|
79
92
|
process_repository = _create_repository(tmp_path, "main")
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Constants for the protected-branch commit gate."""
|
|
2
|
+
|
|
3
|
+
PULL_REQUEST_INSTRUCTION = (
|
|
4
|
+
" Instead: (1) create a feature branch with `git checkout -b <descriptive-branch-name>`, "
|
|
5
|
+
"(2) commit your changes there, "
|
|
6
|
+
"(3) push with `git push -u origin <branch-name>`, "
|
|
7
|
+
"(4) open a pull request with `gh pr create`. "
|
|
8
|
+
"If you must commit to main, the user needs to approve explicitly."
|
|
9
|
+
)
|
|
@@ -87,7 +87,7 @@ VERDICT_DONE: str = "DONE. Add the label now:"
|
|
|
87
87
|
VERDICT_NOT_DONE: str = "NOT DONE. Do not add the done label yet."
|
|
88
88
|
NO_PULL_REQUEST_REMINDER: str = (
|
|
89
89
|
"=== PR DONE CHECKLIST (context reminder, never a block) ===\n"
|
|
90
|
-
"No open pull request found for this branch. Open a
|
|
90
|
+
"No open pull request found for this branch. Open a pull request with gh pr create.\n"
|
|
91
91
|
"A branch is done only when its PR has clean CI, no merge conflicts, and the done label."
|
|
92
92
|
)
|
|
93
93
|
RECHECK_COMMAND_TEMPLATE: str = (
|
package/hooks/pyproject.toml
CHANGED
|
@@ -14,7 +14,6 @@ The checks live in `python_style_checks.py`. Shared source-line splitting, funct
|
|
|
14
14
|
| `exempt_paths.py` | Shared config, test, and hook-infrastructure path exemptions |
|
|
15
15
|
| `fast_save_validators.py` | In-process Write/Edit save-path validator roster |
|
|
16
16
|
| `file_structure_checks.py` | File structure checks for pre-PR validation |
|
|
17
|
-
| `git_checks.py` | Git and GitHub checks for pre-push review |
|
|
18
17
|
| `health_check.py` | Validator availability, dependency, and version checks |
|
|
19
18
|
| `hook_timing_harness.py` | Hook runtime measurement and threshold report |
|
|
20
19
|
| `magic_value_checks.py` | Hardcoded magic-number checks |
|
|
@@ -402,18 +402,6 @@ def run_react_checks(files: List[Path]) -> ValidatorResult:
|
|
|
402
402
|
)
|
|
403
403
|
|
|
404
404
|
|
|
405
|
-
def run_git_checks() -> ValidatorResult:
|
|
406
|
-
"""Run git/GitHub checks."""
|
|
407
|
-
result = invoke_validator_module("git_checks", [])
|
|
408
|
-
|
|
409
|
-
return ValidatorResult(
|
|
410
|
-
name="Git/PR Workflow",
|
|
411
|
-
checks="23,24",
|
|
412
|
-
passed=result.returncode == 0,
|
|
413
|
-
output=result.stdout or result.stderr or "All checks passed",
|
|
414
|
-
)
|
|
415
|
-
|
|
416
|
-
|
|
417
405
|
def run_comment_checks(files: List[Path]) -> ValidatorResult:
|
|
418
406
|
"""Report that comment policy runs through the explicit diff-aware linter."""
|
|
419
407
|
return ValidatorResult(
|
|
@@ -1693,7 +1681,6 @@ def main() -> int:
|
|
|
1693
1681
|
|
|
1694
1682
|
validators.extend([
|
|
1695
1683
|
("File Structure", run_file_structure_checks),
|
|
1696
|
-
("Git/PR", run_git_checks),
|
|
1697
1684
|
])
|
|
1698
1685
|
|
|
1699
1686
|
for i, (name, validator_func) in enumerate(validators, 1):
|
|
@@ -19,7 +19,6 @@ from .run_all_validators import (
|
|
|
19
19
|
format_timing_report,
|
|
20
20
|
main,
|
|
21
21
|
print_header,
|
|
22
|
-
run_git_checks,
|
|
23
22
|
run_python_style_checks,
|
|
24
23
|
run_with_fallback,
|
|
25
24
|
validate_proposed_file,
|
|
@@ -164,11 +163,9 @@ class TestFixFlag:
|
|
|
164
163
|
def test_fix_flag_is_accepted(self) -> None:
|
|
165
164
|
"""Verify --fix flag is recognized without error."""
|
|
166
165
|
with patch("validators.run_all_validators.get_changed_files") as mock_get_files, \
|
|
167
|
-
patch("validators.run_all_validators.run_file_structure_checks") as mock_file
|
|
168
|
-
patch("validators.run_all_validators.run_git_checks") as mock_git:
|
|
166
|
+
patch("validators.run_all_validators.run_file_structure_checks") as mock_file:
|
|
169
167
|
mock_get_files.return_value = []
|
|
170
168
|
mock_file.return_value = _passing_mock_result()
|
|
171
|
-
mock_git.return_value = _passing_mock_result()
|
|
172
169
|
|
|
173
170
|
assert _run_main_with_argv(["run_all_validators.py", "--fix"]) == 0
|
|
174
171
|
|
|
@@ -180,11 +177,10 @@ class TestFixFlag:
|
|
|
180
177
|
patch("validators.run_all_validators.run_test_safety_checks") as mock_test, \
|
|
181
178
|
patch("validators.run_all_validators.run_react_checks") as mock_react, \
|
|
182
179
|
patch("validators.run_all_validators.run_comment_checks") as mock_comment, \
|
|
183
|
-
patch("validators.run_all_validators.run_file_structure_checks") as mock_file
|
|
184
|
-
patch("validators.run_all_validators.run_git_checks") as mock_git:
|
|
180
|
+
patch("validators.run_all_validators.run_file_structure_checks") as mock_file:
|
|
185
181
|
mock_get_files.return_value = [Path("test.py")]
|
|
186
182
|
mock_fix.return_value = ["test.py"]
|
|
187
|
-
for each_mock in (mock_style, mock_test, mock_react, mock_comment, mock_file
|
|
183
|
+
for each_mock in (mock_style, mock_test, mock_react, mock_comment, mock_file):
|
|
188
184
|
each_mock.return_value = _passing_mock_result()
|
|
189
185
|
|
|
190
186
|
_run_main_with_argv(["run_all_validators.py", "--fix"])
|
|
@@ -199,10 +195,9 @@ class TestFixFlag:
|
|
|
199
195
|
patch("validators.run_all_validators.run_test_safety_checks") as mock_test, \
|
|
200
196
|
patch("validators.run_all_validators.run_react_checks") as mock_react, \
|
|
201
197
|
patch("validators.run_all_validators.run_comment_checks") as mock_comment, \
|
|
202
|
-
patch("validators.run_all_validators.run_file_structure_checks") as mock_file
|
|
203
|
-
patch("validators.run_all_validators.run_git_checks") as mock_git:
|
|
198
|
+
patch("validators.run_all_validators.run_file_structure_checks") as mock_file:
|
|
204
199
|
mock_get_files.return_value = [Path("test.py")]
|
|
205
|
-
for each_mock in (mock_style, mock_test, mock_react, mock_comment, mock_file
|
|
200
|
+
for each_mock in (mock_style, mock_test, mock_react, mock_comment, mock_file):
|
|
206
201
|
each_mock.return_value = _passing_mock_result()
|
|
207
202
|
|
|
208
203
|
_run_main_with_argv(["run_all_validators.py"])
|
|
@@ -302,32 +297,6 @@ class TestStderrSurfacing:
|
|
|
302
297
|
|
|
303
298
|
assert "ImportError" in validator_result.output
|
|
304
299
|
|
|
305
|
-
def test_git_check_surfaces_stderr_when_stdout_empty(self) -> None:
|
|
306
|
-
"""When git validator crashes with no stdout, stderr must appear in output."""
|
|
307
|
-
with patch("validators.run_all_validators.invoke_validator_module") as mock_invoke:
|
|
308
|
-
crashed_result = MagicMock()
|
|
309
|
-
crashed_result.returncode = 1
|
|
310
|
-
crashed_result.stdout = ""
|
|
311
|
-
crashed_result.stderr = "SyntaxError: invalid syntax in git_checks.py"
|
|
312
|
-
mock_invoke.return_value = crashed_result
|
|
313
|
-
|
|
314
|
-
validator_result = run_git_checks()
|
|
315
|
-
|
|
316
|
-
assert "SyntaxError" in validator_result.output
|
|
317
|
-
|
|
318
|
-
def test_output_falls_back_to_all_checks_passed_when_both_empty(self) -> None:
|
|
319
|
-
"""When both stdout and stderr are empty and returncode is 0, use fallback."""
|
|
320
|
-
with patch("validators.run_all_validators.invoke_validator_module") as mock_invoke:
|
|
321
|
-
clean_result = MagicMock()
|
|
322
|
-
clean_result.returncode = 0
|
|
323
|
-
clean_result.stdout = ""
|
|
324
|
-
clean_result.stderr = ""
|
|
325
|
-
mock_invoke.return_value = clean_result
|
|
326
|
-
|
|
327
|
-
validator_result = run_git_checks()
|
|
328
|
-
|
|
329
|
-
assert validator_result.output == "All checks passed"
|
|
330
|
-
|
|
331
300
|
|
|
332
301
|
class TestTimingMetrics:
|
|
333
302
|
def test_create_timing_metrics_empty(self) -> None:
|
package/package.json
CHANGED
|
@@ -50,6 +50,6 @@ Delete nothing by hand. The repository deletes the head branch on merge.
|
|
|
50
50
|
|
|
51
51
|
| Rule | Role |
|
|
52
52
|
|---|---|
|
|
53
|
-
| [`git-workflow.md`](git-workflow.md) |
|
|
53
|
+
| [`git-workflow.md`](git-workflow.md) | Open ready for review, and confirm each required context fired after the push |
|
|
54
54
|
| [`ci-owns-the-gate.md`](ci-owns-the-gate.md) | The gate runs once, and it runs on CI |
|
|
55
55
|
| [`correction-lens.md`](correction-lens.md) | A correction becomes a control at the highest layer that can hold it |
|
|
@@ -84,7 +84,7 @@ escalates rather than opening one more quiet pull request. The number is the
|
|
|
84
84
|
repository's setting, and raising or lowering it is one edit there.
|
|
85
85
|
|
|
86
86
|
The `/fix-followups` command drives the whole pass: it reads the brief, fixes
|
|
87
|
-
each rule group, opens a
|
|
87
|
+
each rule group, opens a pull request, and clears the ledger.
|
|
88
88
|
|
|
89
89
|
## Worked example
|
|
90
90
|
|
package/rules/git-workflow.md
CHANGED
|
@@ -15,7 +15,8 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh`. Small
|
|
|
15
15
|
|
|
16
16
|
## Pull request submission rules
|
|
17
17
|
|
|
18
|
-
**
|
|
18
|
+
**Open every pull request ready for review.** Pass `--draft` only when the owner asks
|
|
19
|
+
for a draft.
|
|
19
20
|
|
|
20
21
|
**A release bot's PR body is machine input. Leave it alone.** Release automation reads
|
|
21
22
|
back the body of its own merged pull request to decide it owns that merge. Rewriting the
|
|
@@ -45,12 +46,6 @@ Use `.agents/skills/pull-request/scripts/recover_legacy_author.py
|
|
|
45
46
|
author record. Do not infer a record from age alone. Keep every other record
|
|
46
47
|
untouched.
|
|
47
48
|
|
|
48
|
-
## Git golden rules
|
|
49
|
-
|
|
50
|
-
1. **Draft before push.** Put a pull request in draft state before you push to it.
|
|
51
|
-
- Before push: `gh pr ready --undo`
|
|
52
|
-
- After review approved: `gh pr ready`
|
|
53
|
-
|
|
54
49
|
## Confirm the required checks fired, and let CI run them
|
|
55
50
|
|
|
56
51
|
The gate runs once, and it runs on CI. Push the branch and read its verdict.
|
|
@@ -50,5 +50,5 @@ A repository that merges through a merge queue also runs the check on `merge_gro
|
|
|
50
50
|
| Rule | Role |
|
|
51
51
|
|---|---|
|
|
52
52
|
| [`agent-merges-its-own-green-pull-request.md`](agent-merges-its-own-green-pull-request.md) | The agent that drives a pull request merges it once its gate passes |
|
|
53
|
-
| [`git-workflow.md`](git-workflow.md) |
|
|
53
|
+
| [`git-workflow.md`](git-workflow.md) | Open ready for review, and confirm each required context fired after the push |
|
|
54
54
|
| [`correction-lens.md`](correction-lens.md) | A correction becomes a control at the highest layer that can hold it |
|
|
@@ -72,7 +72,7 @@ USAGE_TEXT: str = (
|
|
|
72
72
|
)
|
|
73
73
|
|
|
74
74
|
BRIEF_HEADER: str = (
|
|
75
|
-
"Fix every finding below in one change, then open a
|
|
75
|
+
"Fix every finding below in one change, then open a pull request "
|
|
76
76
|
"for it. Each finding is non-breaking, so the change that raised it "
|
|
77
77
|
"already shipped. Keep the fixes mechanical, touch no behavior, and run "
|
|
78
78
|
"the repository's own checks before pushing."
|
|
@@ -130,7 +130,8 @@ def test_brief_names_every_recorded_finding_and_the_repository(tmp_path: Path) -
|
|
|
130
130
|
assert exit_code == 0
|
|
131
131
|
assert "instruction-git-mode" in output_text
|
|
132
132
|
assert "wrong mode" in output_text
|
|
133
|
-
assert "
|
|
133
|
+
assert "open a pull request for it" in output_text
|
|
134
|
+
assert "draft pull request" not in output_text
|
|
134
135
|
|
|
135
136
|
|
|
136
137
|
def test_brief_asks_for_no_work_on_an_empty_ledger(tmp_path: Path) -> None:
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
"""Git and GitHub validation checks for pre-push review."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import subprocess
|
|
5
|
-
import sys
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
from typing import List
|
|
8
|
-
|
|
9
|
-
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
10
|
-
|
|
11
|
-
SUBPROCESS_TIMEOUT_SECONDS = 30
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass
|
|
15
|
-
class Violation:
|
|
16
|
-
"""Represents a validation violation."""
|
|
17
|
-
file: str
|
|
18
|
-
line: int
|
|
19
|
-
message: str
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def get_current_branch() -> str:
|
|
23
|
-
"""Get the current git branch name."""
|
|
24
|
-
try:
|
|
25
|
-
result = subprocess.run(
|
|
26
|
-
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
27
|
-
capture_output=True,
|
|
28
|
-
text=True,
|
|
29
|
-
check=True,
|
|
30
|
-
timeout=SUBPROCESS_TIMEOUT_SECONDS,
|
|
31
|
-
creationflags=hidden_window_creation_flags(),
|
|
32
|
-
)
|
|
33
|
-
return result.stdout.strip()
|
|
34
|
-
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
35
|
-
return ""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def check_draft_pr_state() -> List[Violation]:
|
|
39
|
-
"""
|
|
40
|
-
Check that PR is in draft state.
|
|
41
|
-
|
|
42
|
-
Returns empty list if:
|
|
43
|
-
- No PR exists for current branch
|
|
44
|
-
- gh CLI not available
|
|
45
|
-
- PR is in draft state
|
|
46
|
-
|
|
47
|
-
Returns violation if:
|
|
48
|
-
- PR is not in draft state
|
|
49
|
-
"""
|
|
50
|
-
branch = get_current_branch()
|
|
51
|
-
if not branch:
|
|
52
|
-
return []
|
|
53
|
-
|
|
54
|
-
try:
|
|
55
|
-
pr_info = subprocess.run(
|
|
56
|
-
["gh", "pr", "list", "--head", branch, "--json", "number,isDraft"],
|
|
57
|
-
capture_output=True,
|
|
58
|
-
text=True,
|
|
59
|
-
check=True,
|
|
60
|
-
timeout=SUBPROCESS_TIMEOUT_SECONDS,
|
|
61
|
-
creationflags=hidden_window_creation_flags(),
|
|
62
|
-
)
|
|
63
|
-
except FileNotFoundError:
|
|
64
|
-
return []
|
|
65
|
-
except subprocess.CalledProcessError:
|
|
66
|
-
return []
|
|
67
|
-
except subprocess.TimeoutExpired:
|
|
68
|
-
return []
|
|
69
|
-
|
|
70
|
-
if not pr_info.stdout.strip():
|
|
71
|
-
return []
|
|
72
|
-
|
|
73
|
-
try:
|
|
74
|
-
pr_data = json.loads(pr_info.stdout)
|
|
75
|
-
except json.JSONDecodeError:
|
|
76
|
-
return []
|
|
77
|
-
|
|
78
|
-
if not pr_data:
|
|
79
|
-
return []
|
|
80
|
-
|
|
81
|
-
is_draft = pr_data[0].get("isDraft", False)
|
|
82
|
-
|
|
83
|
-
if is_draft:
|
|
84
|
-
return []
|
|
85
|
-
|
|
86
|
-
return [
|
|
87
|
-
Violation(
|
|
88
|
-
file="",
|
|
89
|
-
line=0,
|
|
90
|
-
message="PR must be in draft state before pushing. Run: gh pr ready --undo",
|
|
91
|
-
)
|
|
92
|
-
]
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def main() -> None:
|
|
96
|
-
"""Run all git checks and exit with appropriate code."""
|
|
97
|
-
violations: List[Violation] = []
|
|
98
|
-
|
|
99
|
-
violations.extend(check_draft_pr_state())
|
|
100
|
-
|
|
101
|
-
if violations:
|
|
102
|
-
for violation in violations:
|
|
103
|
-
print(violation.message)
|
|
104
|
-
sys.exit(1)
|
|
105
|
-
|
|
106
|
-
sys.exit(0)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if __name__ == "__main__":
|
|
110
|
-
main()
|