@hy-sde-org/dsh-tool-git 0.1.1-rc.2
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/LICENSE +37 -0
- package/README.md +34 -0
- package/dist/commit.d.ts +63 -0
- package/dist/commit.d.ts.map +1 -0
- package/dist/commit.js +534 -0
- package/dist/commit.js.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/invariant.d.ts +16 -0
- package/dist/invariant.d.ts.map +1 -0
- package/dist/invariant.js +22 -0
- package/dist/invariant.js.map +1 -0
- package/dist/prompt.d.ts +19 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +25 -0
- package/dist/prompt.js.map +1 -0
- package/dist/review.d.ts +51 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +487 -0
- package/dist/review.js.map +1 -0
- package/examples/agent-preset/agent.cordis.yml +21 -0
- package/examples/agent-preset/preset.yml +3 -0
- package/package.json +85 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hy-sde
|
|
4
|
+
|
|
5
|
+
This repository is a port of the oh-my-pi memory surface (retain / recall /
|
|
6
|
+
reflect / memory_edit / learn and the durable project-scoped local memory
|
|
7
|
+
backend) onto the DeepSeek Harness `ctx.memory` service contract — see
|
|
8
|
+
port_omp.md item 4 in the hy-sde fork. The ported portions are distributed
|
|
9
|
+
under the same MIT license with their original copyright held by their
|
|
10
|
+
respective authors:
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2025 Mario Zechner
|
|
13
|
+
Copyright (c) 2025-2026 Can Bölük
|
|
14
|
+
|
|
15
|
+
See THIRD-PARTY-NOTICES.md for full provenance per module.
|
|
16
|
+
|
|
17
|
+
Parts of this repository are derived from DeepSeek Haarness
|
|
18
|
+
(https://github.com/deepseek-ai/deepseek-harness), MIT License,
|
|
19
|
+
Copyright (c) 2026 DeepSeek.
|
|
20
|
+
|
|
21
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
22
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
23
|
+
in the Software without restriction, including without limitation the rights
|
|
24
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
25
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
26
|
+
furnished to do so, subject to the following conditions:
|
|
27
|
+
|
|
28
|
+
The above copyright notice and this permission notice shall be included in all
|
|
29
|
+
copies or substantial portions of the Software.
|
|
30
|
+
|
|
31
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
32
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
33
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
34
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
35
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
36
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
37
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @hy-sde-org/dsh-tool-git
|
|
2
|
+
|
|
3
|
+
Model-facing agentic git tools over the host `ctx.git` service:
|
|
4
|
+
|
|
5
|
+
- **`commit`** — analyze the current git changes and produce a
|
|
6
|
+
conventional-commit split proposal (read-only; never edits the repo).
|
|
7
|
+
- **`commit_apply`** — execute a validated split-commit plan: hunk-aware
|
|
8
|
+
staging, lock-file autoplacement, topological dependency order, cycle
|
|
9
|
+
rejection before any write, atomic per-commit execution, `dryRun` preview.
|
|
10
|
+
- **`review`** — fan the diff out to parallel reviewer subagents, rank every
|
|
11
|
+
finding P0–P3 with confidence, and aggregate a ship/reject verdict.
|
|
12
|
+
|
|
13
|
+
Plus the `git:tools` system-prompt section. Agent-plane: the package mounts
|
|
14
|
+
as a preset row (`inject` = tools, systemPrompt, git) and resolves the host
|
|
15
|
+
`git` service; it registers no service of its own.
|
|
16
|
+
|
|
17
|
+
## Install & mount
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
dsh plugin --profile web add @hy-sde-org/dsh-git @hy-sde-org/dsh-tool-git
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Copy `examples/agent-preset/` to `~/.dsh/.agent-presets/<id>/` and select it
|
|
24
|
+
in the Web UI preset picker. The preset row configures
|
|
25
|
+
`reviewProvider: spawn` and `maxReviewers: 4`.
|
|
26
|
+
|
|
27
|
+
## Development
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pnpm install
|
|
31
|
+
pnpm run check # tsc --noEmit
|
|
32
|
+
pnpm run build # tsc -p tsconfig.build.json → dist/
|
|
33
|
+
pnpm run test # vitest (commit + review end-to-end on real temp repos)
|
|
34
|
+
```
|
package/dist/commit.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two commit tools over `ctx.git`: `commit` analyzes the staged/working
|
|
3
|
+
* tree and returns ground truth plus a suggested split plan, and
|
|
4
|
+
* `commit_apply` validates and executes a plan the model authors (dependency
|
|
5
|
+
* order, cycle rejection, lock-file placement, hunk staging, atomic commits).
|
|
6
|
+
*
|
|
7
|
+
* Port of omp (oh-my-pi)'s agentic commit flow, re-architected for the harness:
|
|
8
|
+
* omp runs its own hidden LLM session; here the model scripts the plan between
|
|
9
|
+
* the two tool calls, so every decision is visible and reviewable (see
|
|
10
|
+
* port_omp.md item: commit). The deterministic parts — validation, topo order,
|
|
11
|
+
* lock-file assignment, execution — are direct ports.
|
|
12
|
+
* @module @hy-sde-org/dsh-tool-git/commit
|
|
13
|
+
*/
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
15
|
+
import type { ToolExecution } from '@deepseek-ai/dsh-tools';
|
|
16
|
+
import type { CommitType, NumstatEntry } from '@hy-sde-org/dsh-git';
|
|
17
|
+
/** Call working directory resolution shared by the git tools. */
|
|
18
|
+
export declare function resolveCwd(exec: ToolExecution, cwdArg: string | undefined): string;
|
|
19
|
+
/** Configuration consumed by the commit tools. */
|
|
20
|
+
export interface CommitToolConfig {
|
|
21
|
+
/** Char cap on the diff text included in a `commit` analysis (default 60000). */
|
|
22
|
+
maxDiffChars?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface CommitChangeSuggestion {
|
|
25
|
+
path: string;
|
|
26
|
+
additions: number;
|
|
27
|
+
deletions: number;
|
|
28
|
+
}
|
|
29
|
+
export interface CommitAnalysisValue {
|
|
30
|
+
cwd: string;
|
|
31
|
+
branch?: string;
|
|
32
|
+
staged: boolean;
|
|
33
|
+
files: NumstatEntry[];
|
|
34
|
+
untrackedFiles: string[];
|
|
35
|
+
diff: string;
|
|
36
|
+
diffTruncated: boolean;
|
|
37
|
+
trivial?: {
|
|
38
|
+
type: CommitType;
|
|
39
|
+
summary: string;
|
|
40
|
+
};
|
|
41
|
+
lockFilesPending: string[];
|
|
42
|
+
suggestedPlan: readonly unknown[];
|
|
43
|
+
warnings: string[];
|
|
44
|
+
analysisGuidance: string;
|
|
45
|
+
}
|
|
46
|
+
export declare function applyCommitTool(ctx: Context, config?: CommitToolConfig): void;
|
|
47
|
+
export interface CommitCreated {
|
|
48
|
+
position: number;
|
|
49
|
+
hash: string;
|
|
50
|
+
message: string;
|
|
51
|
+
changes: string[];
|
|
52
|
+
}
|
|
53
|
+
export interface CommitApplyValue {
|
|
54
|
+
mode: 'single' | 'split';
|
|
55
|
+
created: CommitCreated[];
|
|
56
|
+
messages: string[];
|
|
57
|
+
warnings: string[];
|
|
58
|
+
dryRun: boolean;
|
|
59
|
+
}
|
|
60
|
+
export declare function applyCommitApplyTool(ctx: Context, _config?: {
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
}): void;
|
|
63
|
+
//# sourceMappingURL=commit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"commit.d.ts","sourceRoot":"","sources":["../src/commit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAS3D,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAqC,MAAM,qBAAqB,CAAA;AAOtG,iEAAiE;AACjE,wBAAgB,UAAU,CAAC,IAAI,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAMlF;AAOD,kDAAkD;AAClD,MAAM,WAAW,gBAAgB;IAC/B,iFAAiF;IACjF,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAmBD,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,OAAO,CAAA;IACf,KAAK,EAAE,YAAY,EAAE,CAAA;IACrB,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,aAAa,EAAE,OAAO,CAAA;IACtB,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,UAAU,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;IAC/C,gBAAgB,EAAE,MAAM,EAAE,CAAA;IAC1B,aAAa,EAAE,SAAS,OAAO,EAAE,CAAA;IACjC,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,gBAAgB,EAAE,MAAM,CAAA;CACzB;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,gBAAqB,GAAG,IAAI,CA4JjF;AAuFD,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAA;IACxB,OAAO,EAAE,aAAa,EAAE,CAAA;IACxB,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,MAAM,EAAE,OAAO,CAAA;CAChB;AA0BD,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,IAAI,CAuO7F"}
|
package/dist/commit.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two commit tools over `ctx.git`: `commit` analyzes the staged/working
|
|
3
|
+
* tree and returns ground truth plus a suggested split plan, and
|
|
4
|
+
* `commit_apply` validates and executes a plan the model authors (dependency
|
|
5
|
+
* order, cycle rejection, lock-file placement, hunk staging, atomic commits).
|
|
6
|
+
*
|
|
7
|
+
* Port of omp (oh-my-pi)'s agentic commit flow, re-architected for the harness:
|
|
8
|
+
* omp runs its own hidden LLM session; here the model scripts the plan between
|
|
9
|
+
* the two tool calls, so every decision is visible and reviewable (see
|
|
10
|
+
* port_omp.md item: commit). The deterministic parts — validation, topo order,
|
|
11
|
+
* lock-file assignment, execution — are direct ports.
|
|
12
|
+
* @module @hy-sde-org/dsh-tool-git/commit
|
|
13
|
+
*/
|
|
14
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
15
|
+
import { formatCommitMessage, assignLockFilesToPlan, detectTrivialChange, computeDependencyOrder, validateHunkSelections, EXCLUDED_LOCK_FILES, } from '@hy-sde-org/dsh-git';
|
|
16
|
+
/** Commit-type vocabulary for schema + validation (kept in sync with types). */
|
|
17
|
+
const COMMIT_TYPES = [
|
|
18
|
+
'feat', 'fix', 'refactor', 'perf', 'docs', 'test', 'build', 'ci', 'chore', 'style', 'revert',
|
|
19
|
+
];
|
|
20
|
+
/** Call working directory resolution shared by the git tools. */
|
|
21
|
+
export function resolveCwd(exec, cwdArg) {
|
|
22
|
+
const base = exec.agent?.session.header.cwd;
|
|
23
|
+
if (typeof base === 'string' && base.length > 0) {
|
|
24
|
+
return cwdArg === undefined ? base : (cwdArg.startsWith('/') ? cwdArg : joinPath(base, cwdArg));
|
|
25
|
+
}
|
|
26
|
+
return cwdArg ?? process.cwd();
|
|
27
|
+
}
|
|
28
|
+
function joinPath(base, rel) {
|
|
29
|
+
if (rel === '.' || rel === './')
|
|
30
|
+
return base;
|
|
31
|
+
return `${base.replace(/\/+$/, '')}/${rel.replace(/^\/+/, '')}`;
|
|
32
|
+
}
|
|
33
|
+
const DEFAULT_MAX_DIFF_CHARS = 60_000;
|
|
34
|
+
export function applyCommitTool(ctx, config = {}) {
|
|
35
|
+
const maxDiffChars = config.maxDiffChars ?? DEFAULT_MAX_DIFF_CHARS;
|
|
36
|
+
ctx.tools.register(defineTool({
|
|
37
|
+
name: 'commit',
|
|
38
|
+
description: 'Analyze the current git changes and produce a conventional-commit split proposal. '
|
|
39
|
+
+ 'Reads the staged (or auto-staged working) tree, returns per-file add/delete counts, the bounded diff text, '
|
|
40
|
+
+ 'lock-file hints, and a suggested plan skeleton. After reading the analysis, author a precise `SplitCommitPlan` '
|
|
41
|
+
+ '(types, scopes, summaries, dependencies) and pass it to `commit_apply` to execute. Never edit files here: this '
|
|
42
|
+
+ 'tool is read-only and never writes to the repository.',
|
|
43
|
+
parameters: {
|
|
44
|
+
stagedOnly: { type: 'boolean', description: 'Analyze only what is already staged; when false and nothing is staged, stage all changes first (default false).' },
|
|
45
|
+
context: { type: 'string', description: 'Optional user context for planning: intent, headline change, reviewers, issue refs.' },
|
|
46
|
+
cwd: { type: 'string', description: 'Working directory; defaults to the session workspace.' },
|
|
47
|
+
},
|
|
48
|
+
output: {
|
|
49
|
+
schema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
properties: {
|
|
53
|
+
cwd: { type: 'string', required: true },
|
|
54
|
+
branch: { type: 'string' },
|
|
55
|
+
staged: { type: 'boolean', required: true },
|
|
56
|
+
files: {
|
|
57
|
+
type: 'array',
|
|
58
|
+
required: true,
|
|
59
|
+
items: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
properties: {
|
|
63
|
+
path: { type: 'string', required: true },
|
|
64
|
+
additions: { type: 'integer', required: true },
|
|
65
|
+
deletions: { type: 'integer', required: true },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
untrackedFiles: { type: 'array', required: true, items: { type: 'string' } },
|
|
70
|
+
diff: { type: 'string', required: true },
|
|
71
|
+
diffTruncated: { type: 'boolean', required: true },
|
|
72
|
+
trivial: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
properties: {
|
|
76
|
+
type: { type: 'string', required: true, enum: [...COMMIT_TYPES] },
|
|
77
|
+
summary: { type: 'string', required: true },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
lockFilesPending: { type: 'array', required: true, items: { type: 'string' } },
|
|
81
|
+
suggestedPlan: {
|
|
82
|
+
type: 'array',
|
|
83
|
+
required: true,
|
|
84
|
+
items: { type: 'json' },
|
|
85
|
+
},
|
|
86
|
+
warnings: { type: 'array', required: true, items: { type: 'string' } },
|
|
87
|
+
analysisGuidance: { type: 'string', required: true },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
render: (_args, value) => [{
|
|
91
|
+
type: 'text',
|
|
92
|
+
text: renderCommitAnalysis(value),
|
|
93
|
+
}],
|
|
94
|
+
},
|
|
95
|
+
isConcurrencySafe: () => false,
|
|
96
|
+
async execute(args, exec) {
|
|
97
|
+
const git = ctx.git;
|
|
98
|
+
const cwd = resolveCwd(exec, args.cwd);
|
|
99
|
+
if (!(await git.isRepo(cwd, exec.signal))) {
|
|
100
|
+
throw new Error(`commit requires a git repository: ${cwd} is not inside a working tree`);
|
|
101
|
+
}
|
|
102
|
+
const warnings = [];
|
|
103
|
+
let stagedFiles = await git.diff.changedFiles(cwd, { cached: true, signal: exec.signal });
|
|
104
|
+
if (stagedFiles.length === 0 && !args.stagedOnly) {
|
|
105
|
+
const status = await git.status(cwd, exec.signal);
|
|
106
|
+
if (status.unstaged > 0 || status.untracked > 0) {
|
|
107
|
+
await git.addAll(cwd, [], exec.signal);
|
|
108
|
+
warnings.push('nothing was staged; staged all working-tree changes automatically for analysis');
|
|
109
|
+
}
|
|
110
|
+
stagedFiles = await git.diff.changedFiles(cwd, { cached: true, signal: exec.signal });
|
|
111
|
+
}
|
|
112
|
+
if (stagedFiles.length === 0) {
|
|
113
|
+
const status = await git.status(cwd, exec.signal);
|
|
114
|
+
if (status.untracked > 0) {
|
|
115
|
+
warnings.push(`detected ${status.untracked} untracked file(s) not included — rerun without stagedOnly to stage them`);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
cwd,
|
|
119
|
+
staged: false,
|
|
120
|
+
files: [],
|
|
121
|
+
untrackedFiles: [],
|
|
122
|
+
diff: '',
|
|
123
|
+
diffTruncated: false,
|
|
124
|
+
lockFilesPending: [],
|
|
125
|
+
suggestedPlan: [],
|
|
126
|
+
warnings,
|
|
127
|
+
analysisGuidance: 'No staged changes and nothing was auto-staged. Nothing to commit.',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const numstat = await git.diff.numstat(cwd, { cached: true, signal: exec.signal });
|
|
131
|
+
const branch = (await git.branch(cwd, exec.signal)) ?? undefined;
|
|
132
|
+
let diffText = await git.diffText(cwd, { cached: true, binary: true }, exec.signal);
|
|
133
|
+
let diffTruncated = false;
|
|
134
|
+
if (diffText.length > maxDiffChars) {
|
|
135
|
+
diffText = diffText.slice(0, maxDiffChars);
|
|
136
|
+
diffTruncated = true;
|
|
137
|
+
warnings.push('diff truncated: use read/grep on the listed files for the full picture');
|
|
138
|
+
}
|
|
139
|
+
const trivial = detectTrivialChange(diffText);
|
|
140
|
+
const lockFilesPending = stagedFiles.filter((file) => {
|
|
141
|
+
const parts = file.split('/');
|
|
142
|
+
const basename = parts[parts.length - 1] ?? '';
|
|
143
|
+
return EXCLUDED_LOCK_FILES.has(basename);
|
|
144
|
+
});
|
|
145
|
+
// Lock files are auto-placed by commit_apply; keep them out of the
|
|
146
|
+
// plan skeleton so the model plans only real changes.
|
|
147
|
+
const planSource = stagedFiles.filter((file) => {
|
|
148
|
+
const parts = file.split('/');
|
|
149
|
+
const basename = parts[parts.length - 1] ?? '';
|
|
150
|
+
return !EXCLUDED_LOCK_FILES.has(basename);
|
|
151
|
+
});
|
|
152
|
+
const suggestedPlan = trivial
|
|
153
|
+
? [{
|
|
154
|
+
changes: planSource.map(path => ({ path, hunks: { type: 'all' } })),
|
|
155
|
+
type: trivial.type,
|
|
156
|
+
scope: null,
|
|
157
|
+
summary: trivial.summary,
|
|
158
|
+
details: [],
|
|
159
|
+
issueRefs: [],
|
|
160
|
+
dependencies: [],
|
|
161
|
+
rationale: 'trivial change detected (formatted/imports); single commit',
|
|
162
|
+
}]
|
|
163
|
+
: buildSuggestedGroups(planSource);
|
|
164
|
+
const analysisGuidance = buildAnalysisGuidance(trivial, planSource.length, suggestedPlan.length);
|
|
165
|
+
return {
|
|
166
|
+
cwd,
|
|
167
|
+
...(branch !== undefined ? { branch } : {}),
|
|
168
|
+
staged: true,
|
|
169
|
+
files: numstat,
|
|
170
|
+
untrackedFiles: [],
|
|
171
|
+
diff: diffText,
|
|
172
|
+
diffTruncated,
|
|
173
|
+
...(trivial !== null ? { trivial } : {}),
|
|
174
|
+
lockFilesPending,
|
|
175
|
+
suggestedPlan: suggestedPlan,
|
|
176
|
+
warnings,
|
|
177
|
+
analysisGuidance,
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
/** Deterministic per-top-directory grouping skeleton for the model to refine. */
|
|
183
|
+
function buildSuggestedGroups(stagedFiles) {
|
|
184
|
+
const byDir = new Map();
|
|
185
|
+
for (const file of stagedFiles) {
|
|
186
|
+
const slash = file.indexOf('/');
|
|
187
|
+
const dir = slash === -1 ? '(root)' : file.slice(0, slash);
|
|
188
|
+
const list = byDir.get(dir);
|
|
189
|
+
if (list)
|
|
190
|
+
list.push(file);
|
|
191
|
+
else
|
|
192
|
+
byDir.set(dir, [file]);
|
|
193
|
+
}
|
|
194
|
+
const groups = [];
|
|
195
|
+
for (const [dir, files] of byDir) {
|
|
196
|
+
groups.push({
|
|
197
|
+
changes: files.map(path => ({ path, hunks: { type: 'all' } })),
|
|
198
|
+
type: 'chore',
|
|
199
|
+
scope: dir === '(root)' ? null : dir,
|
|
200
|
+
summary: '',
|
|
201
|
+
details: [],
|
|
202
|
+
issueRefs: [],
|
|
203
|
+
dependencies: [],
|
|
204
|
+
rationale: `placeholder group for ${files.length} file(s) under "${dir}" — edit type/scope/summary and add dependencies`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return groups;
|
|
208
|
+
}
|
|
209
|
+
function buildAnalysisGuidance(trivial, fileCount, groupCount) {
|
|
210
|
+
if (trivial) {
|
|
211
|
+
return `Trivial change (${trivial.type}: ${trivial.summary}) — call commit_apply with the suggested single commit group to commit directly.`;
|
|
212
|
+
}
|
|
213
|
+
return 'Author a split plan in `commit_apply`: pick a conventional type per group, write a concise imperative summary, '
|
|
214
|
+
+ 'group related files together, and declare explicit `dependencies` indices when ordering matters (e.g. a lock file '
|
|
215
|
+
+ 'after its manifest, generated code after the generator). Order is derived topologically; cycles are rejected. '
|
|
216
|
+
+ `The suggested skeleton groups ${fileCount} file(s) into ${groupCount} group(s) — refine it, do not copy summaries verbatim.`;
|
|
217
|
+
}
|
|
218
|
+
function renderCommitAnalysis(value) {
|
|
219
|
+
const lines = [];
|
|
220
|
+
lines.push(`commit analysis — ${value.cwd}${value.branch ? ` (branch ${value.branch})` : ''}`);
|
|
221
|
+
if (value.files.length === 0) {
|
|
222
|
+
lines.push('No staged changes to commit.');
|
|
223
|
+
return lines.join('\n');
|
|
224
|
+
}
|
|
225
|
+
lines.push(`Files (${value.files.length} staged):`);
|
|
226
|
+
for (const file of value.files) {
|
|
227
|
+
lines.push(` ${file.path} +${file.additions} -${file.deletions}`);
|
|
228
|
+
}
|
|
229
|
+
if (value.lockFilesPending.length > 0) {
|
|
230
|
+
lines.push(`Lock files auto-placed by commit_apply: ${value.lockFilesPending.join(', ')}`);
|
|
231
|
+
}
|
|
232
|
+
if (value.trivial) {
|
|
233
|
+
lines.push(`Trivial: ${value.trivial.type} — ${value.trivial.summary}`);
|
|
234
|
+
}
|
|
235
|
+
if (value.diff.length > 0) {
|
|
236
|
+
lines.push(value.diffTruncated ? 'Diff (truncated):' : 'Diff:');
|
|
237
|
+
lines.push(value.diff);
|
|
238
|
+
}
|
|
239
|
+
for (const warning of value.warnings)
|
|
240
|
+
lines.push(`warning: ${warning}`);
|
|
241
|
+
return lines.join('\n');
|
|
242
|
+
}
|
|
243
|
+
/** Validate a plan's group fields; returns a readable error per problem. */
|
|
244
|
+
function validateGroupFields(commits) {
|
|
245
|
+
const errors = [];
|
|
246
|
+
for (const [i, group] of commits.entries()) {
|
|
247
|
+
const label = `group ${i + 1}`;
|
|
248
|
+
if (!COMMIT_TYPES.includes(group.type)) {
|
|
249
|
+
errors.push(`${label}: unknown commit type "${String(group.type)}"; use one of ${COMMIT_TYPES.join(', ')}`);
|
|
250
|
+
}
|
|
251
|
+
if (typeof group.summary !== 'string' || group.summary.trim().length === 0) {
|
|
252
|
+
errors.push(`${label}: summary is required and must be non-empty`);
|
|
253
|
+
}
|
|
254
|
+
if (!Array.isArray(group.changes) || group.changes.length === 0) {
|
|
255
|
+
errors.push(`${label}: changes is required and must list at least one path`);
|
|
256
|
+
}
|
|
257
|
+
if (group.scope !== undefined && group.scope !== null && typeof group.scope !== 'string') {
|
|
258
|
+
errors.push(`${label}: scope must be a string or null`);
|
|
259
|
+
}
|
|
260
|
+
if (!Array.isArray(group.dependencies)) {
|
|
261
|
+
errors.push(`${label}: dependencies must be an array of group indices`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return errors;
|
|
265
|
+
}
|
|
266
|
+
export function applyCommitApplyTool(ctx, _config = {}) {
|
|
267
|
+
ctx.tools.register(defineTool({
|
|
268
|
+
name: 'commit_apply',
|
|
269
|
+
description: 'Execute a validated split-commit plan on the staged changes. Requires the plan produced with `commit`: every staged '
|
|
270
|
+
+ 'file must be covered exactly once, lock files are placed automatically, hunk selectors are validated against the '
|
|
271
|
+
+ 'real diff, dependencies are resolved topologically (cycles rejected before anything is written), and each commit is '
|
|
272
|
+
+ 'created atomically in dependency order. Use `dryRun: true` to preview exact messages before writing anything.',
|
|
273
|
+
parameters: {
|
|
274
|
+
commits: {
|
|
275
|
+
type: 'array',
|
|
276
|
+
description: 'Commit groups of the split plan (see commit_apply contract): each has changes [{path, hunks}], type, scope, summary, details, dependencies.',
|
|
277
|
+
items: {
|
|
278
|
+
type: 'object',
|
|
279
|
+
additionalProperties: false,
|
|
280
|
+
properties: {
|
|
281
|
+
changes: {
|
|
282
|
+
type: 'array',
|
|
283
|
+
required: true,
|
|
284
|
+
description: 'Files (and optional hunk selectors) this commit covers; paths must name staged files.',
|
|
285
|
+
items: {
|
|
286
|
+
type: 'object',
|
|
287
|
+
additionalProperties: false,
|
|
288
|
+
properties: {
|
|
289
|
+
path: { type: 'string', required: true },
|
|
290
|
+
hunks: {
|
|
291
|
+
type: 'object',
|
|
292
|
+
description: 'Which part of the file to commit: all (default), indices (1-based hunk numbers), or lines (new-file line range).',
|
|
293
|
+
additionalProperties: false,
|
|
294
|
+
properties: {
|
|
295
|
+
type: { type: 'string', required: true, enum: ['all', 'indices', 'lines'] },
|
|
296
|
+
indices: { type: 'array', items: { type: 'integer' } },
|
|
297
|
+
start: { type: 'integer' },
|
|
298
|
+
end: { type: 'integer' },
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
type: { type: 'string', required: true, enum: [...COMMIT_TYPES], description: 'Conventional-commit type.' },
|
|
305
|
+
scope: { type: 'string', description: 'Optional conventional scope.' },
|
|
306
|
+
summary: { type: 'string', required: true, description: 'Imperative summary line, ≤72 chars.' },
|
|
307
|
+
details: {
|
|
308
|
+
type: 'array',
|
|
309
|
+
description: 'Optional body bullet lines.',
|
|
310
|
+
items: {
|
|
311
|
+
type: 'object',
|
|
312
|
+
additionalProperties: false,
|
|
313
|
+
properties: {
|
|
314
|
+
text: { type: 'string', required: true },
|
|
315
|
+
userVisible: { type: 'boolean' },
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
issueRefs: { type: 'array', items: { type: 'string' } },
|
|
320
|
+
dependencies: { type: 'array', items: { type: 'integer' }, description: 'Zero-based indices of groups that must commit first.' },
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
dryRun: { type: 'boolean', description: 'Validate and print the exact commit messages without writing anything (default false).' },
|
|
325
|
+
push: { type: 'boolean', description: 'Push the branch after committing (default false).' },
|
|
326
|
+
cwd: { type: 'string', description: 'Working directory; defaults to the session workspace.' },
|
|
327
|
+
},
|
|
328
|
+
output: {
|
|
329
|
+
schema: {
|
|
330
|
+
type: 'object',
|
|
331
|
+
additionalProperties: false,
|
|
332
|
+
properties: {
|
|
333
|
+
mode: { type: 'string', required: true, enum: ['single', 'split'] },
|
|
334
|
+
created: {
|
|
335
|
+
type: 'array',
|
|
336
|
+
required: true,
|
|
337
|
+
items: {
|
|
338
|
+
type: 'object',
|
|
339
|
+
additionalProperties: false,
|
|
340
|
+
properties: {
|
|
341
|
+
position: { type: 'integer', required: true },
|
|
342
|
+
hash: { type: 'string', required: true },
|
|
343
|
+
message: { type: 'string', required: true },
|
|
344
|
+
changes: { type: 'array', required: true, items: { type: 'string' } },
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
messages: { type: 'array', required: true, items: { type: 'string' } },
|
|
349
|
+
warnings: { type: 'array', required: true, items: { type: 'string' } },
|
|
350
|
+
dryRun: { type: 'boolean', required: true },
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
render: (_args, value) => [{
|
|
354
|
+
type: 'text',
|
|
355
|
+
text: renderCommitApply(value),
|
|
356
|
+
}],
|
|
357
|
+
},
|
|
358
|
+
isConcurrencySafe: () => false,
|
|
359
|
+
async execute(args, exec) {
|
|
360
|
+
const git = ctx.git;
|
|
361
|
+
const cwd = resolveCwd(exec, args.cwd);
|
|
362
|
+
if (!(await git.isRepo(cwd, exec.signal))) {
|
|
363
|
+
throw new Error(`commit_apply requires a git repository: ${cwd} is not inside a working tree`);
|
|
364
|
+
}
|
|
365
|
+
if (!Array.isArray(args.commits) || args.commits.length === 0) {
|
|
366
|
+
throw new Error('commit_apply requires a non-empty commits plan (call `commit` first to analyze)');
|
|
367
|
+
}
|
|
368
|
+
// Snapshot the actual staged state; validation and execution trust this,
|
|
369
|
+
// never the plan author's stale view.
|
|
370
|
+
const stagedDiff = await git.diffText(cwd, { cached: true, binary: true }, exec.signal);
|
|
371
|
+
const stagedFiles = await git.diff.changedFiles(cwd, { cached: true, signal: exec.signal });
|
|
372
|
+
const plan = { commits: args.commits.map(cloneGroup), warnings: [] };
|
|
373
|
+
const warnings = [...plan.warnings];
|
|
374
|
+
if (stagedFiles.length === 0) {
|
|
375
|
+
throw new Error('nothing is staged; run `commit` (adds changes) or `git add` first, or review `commit` with stagedOnly');
|
|
376
|
+
}
|
|
377
|
+
assignLockFilesToPlan(plan, stagedFiles);
|
|
378
|
+
// Coverage: every staged file planned exactly once.
|
|
379
|
+
const planned = new Set();
|
|
380
|
+
const duplicates = [];
|
|
381
|
+
for (const group of plan.commits) {
|
|
382
|
+
for (const change of group.changes) {
|
|
383
|
+
if (planned.has(change.path))
|
|
384
|
+
duplicates.push(change.path);
|
|
385
|
+
planned.add(change.path);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const missing = stagedFiles.filter(file => !planned.has(file));
|
|
389
|
+
if (duplicates.length > 0) {
|
|
390
|
+
throw new Error(`Split commit plan assigns files to multiple groups: ${[...new Set(duplicates)].join(', ')}`);
|
|
391
|
+
}
|
|
392
|
+
if (missing.length > 0) {
|
|
393
|
+
throw new Error(`Split commit plan missing staged files: ${missing.join(', ')}`);
|
|
394
|
+
}
|
|
395
|
+
// Hunk selectors must resolve against the real diff.
|
|
396
|
+
const hunkErrors = validateHunkSelections(stagedDiff, plan.commits.flatMap(group => group.changes));
|
|
397
|
+
if (hunkErrors.length > 0) {
|
|
398
|
+
throw new Error(`Invalid hunk selections:\n${hunkErrors.map(error => `- ${error}`).join('\n')}`);
|
|
399
|
+
}
|
|
400
|
+
const fieldErrors = validateGroupFields(plan.commits);
|
|
401
|
+
if (fieldErrors.length > 0) {
|
|
402
|
+
throw new Error(fieldErrors.map(error => `- ${error}`).join('\n'));
|
|
403
|
+
}
|
|
404
|
+
const order = computeDependencyOrder(plan.commits);
|
|
405
|
+
if ('error' in order) {
|
|
406
|
+
throw new Error(`Plan rejected before anything was written: ${order.error}`);
|
|
407
|
+
}
|
|
408
|
+
const messages = order.map((index) => {
|
|
409
|
+
const group = plan.commits[index];
|
|
410
|
+
if (!group) {
|
|
411
|
+
throw new Error('Plan rejected before anything was written: commit order references an unknown group');
|
|
412
|
+
}
|
|
413
|
+
return formatCommitMessage({ type: group.type, scope: group.scope, details: group.details ?? [], issueRefs: group.issueRefs ?? [] }, normalizeSummary(group.summary));
|
|
414
|
+
});
|
|
415
|
+
if (args.dryRun) {
|
|
416
|
+
return {
|
|
417
|
+
mode: order.length === 1 ? 'single' : 'split',
|
|
418
|
+
created: [],
|
|
419
|
+
messages,
|
|
420
|
+
warnings,
|
|
421
|
+
dryRun: true,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
const created = [];
|
|
425
|
+
await git.resetIndex(cwd, [], exec.signal);
|
|
426
|
+
try {
|
|
427
|
+
for (let position = 0; position < order.length; position++) {
|
|
428
|
+
const index = order[position];
|
|
429
|
+
const group = index === undefined ? undefined : plan.commits[index];
|
|
430
|
+
if (index === undefined || group === undefined) {
|
|
431
|
+
throw new Error('Plan rejected before anything was written: commit order references an unknown group');
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
await git.stageHunks(cwd, group.changes, { rawDiff: stagedDiff, signal: exec.signal });
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
438
|
+
throw new Error(`${position} of ${order.length} commits created so far; failed to stage group ${position + 1}: ${message}. `
|
|
439
|
+
+ 'No changes were lost — remaining changes are unstaged.');
|
|
440
|
+
}
|
|
441
|
+
const message = formatCommitMessage({ type: group.type, scope: group.scope, details: group.details ?? [], issueRefs: group.issueRefs ?? [] }, normalizeSummary(group.summary));
|
|
442
|
+
try {
|
|
443
|
+
await git.commit(cwd, message, { signal: exec.signal });
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
447
|
+
throw new Error(`Commit ${position + 1} of ${order.length} failed: ${detail}. `
|
|
448
|
+
+ `${position} of ${order.length} commits created; no changes were lost.`);
|
|
449
|
+
}
|
|
450
|
+
const hash = (await git.log(cwd, { max: 1, signal: exec.signal }))[0]?.hash ?? '';
|
|
451
|
+
created.push({
|
|
452
|
+
position: position + 1,
|
|
453
|
+
hash,
|
|
454
|
+
message,
|
|
455
|
+
changes: group.changes.map(change => change.path),
|
|
456
|
+
});
|
|
457
|
+
await git.resetIndex(cwd, [], exec.signal);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
// Leave the index empty so the user's worktree changes stay intact and
|
|
462
|
+
// inspectable; nothing was lost, no partial staging is leaked.
|
|
463
|
+
await git.resetIndex(cwd, [], exec.signal).catch(() => undefined);
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
if (args.push) {
|
|
467
|
+
await git.push(cwd, { signal: exec.signal });
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
mode: created.length === 1 ? 'single' : 'split',
|
|
471
|
+
created,
|
|
472
|
+
messages,
|
|
473
|
+
warnings,
|
|
474
|
+
dryRun: false,
|
|
475
|
+
};
|
|
476
|
+
},
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
479
|
+
/** Deep-clone an input group into the service's own table (schema-validated fields). */
|
|
480
|
+
function cloneGroup(group) {
|
|
481
|
+
return {
|
|
482
|
+
changes: (group.changes ?? []).map(change => ({
|
|
483
|
+
path: change.path,
|
|
484
|
+
hunks: normalizeHunks(change.hunks),
|
|
485
|
+
})),
|
|
486
|
+
type: group.type,
|
|
487
|
+
scope: group.scope ?? null,
|
|
488
|
+
summary: normalizeSummary(group.summary),
|
|
489
|
+
details: (group.details ?? []).map(detail => ({
|
|
490
|
+
text: detail.text,
|
|
491
|
+
userVisible: detail.userVisible ?? true,
|
|
492
|
+
})),
|
|
493
|
+
issueRefs: group.issueRefs ?? [],
|
|
494
|
+
dependencies: group.dependencies ?? [],
|
|
495
|
+
...group.rationale !== undefined ? { rationale: group.rationale } : {},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function normalizeSummary(summary) {
|
|
499
|
+
return summary.trim().replace(/\s+/g, ' ');
|
|
500
|
+
}
|
|
501
|
+
function normalizeHunks(hunks) {
|
|
502
|
+
if (hunks === undefined || hunks === null)
|
|
503
|
+
return { type: 'all' };
|
|
504
|
+
const value = hunks;
|
|
505
|
+
if (value.type === 'indices')
|
|
506
|
+
return { type: 'indices', indices: (value.indices ?? []).map(Math.floor) };
|
|
507
|
+
if (value.type === 'lines') {
|
|
508
|
+
return { type: 'lines', start: Math.floor(value.start ?? 0), end: Math.floor(value.end ?? 0) };
|
|
509
|
+
}
|
|
510
|
+
return { type: 'all' };
|
|
511
|
+
}
|
|
512
|
+
function renderCommitApply(value) {
|
|
513
|
+
const lines = [];
|
|
514
|
+
if (value.dryRun) {
|
|
515
|
+
lines.push('Split commit plan (dry run):');
|
|
516
|
+
}
|
|
517
|
+
else if (value.created.length === 0) {
|
|
518
|
+
lines.push('Nothing committed.');
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
lines.push(value.mode === 'split' ? `Created ${value.created.length} commits:` : 'Created 1 commit:');
|
|
522
|
+
}
|
|
523
|
+
for (const item of value.messages)
|
|
524
|
+
lines.push(` ${item.replace(/\n/g, '\n ')}`);
|
|
525
|
+
if (!value.dryRun && value.created.length > 0) {
|
|
526
|
+
for (const item of value.created) {
|
|
527
|
+
lines.push(` [${item.position}/${value.created.length}] ${item.hash.slice(0, 8)} ${item.message.split('\n')[0]}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (const warning of value.warnings)
|
|
531
|
+
lines.push(`warning: ${warning}`);
|
|
532
|
+
return lines.join('\n');
|
|
533
|
+
}
|
|
534
|
+
//# sourceMappingURL=commit.js.map
|