cabloy 5.1.191 → 5.1.193
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/.cabloy-version +1 -1
- package/.github/workflows/agent-governance.yml +39 -0
- package/.github/workflows/docs-pages.yml +1 -0
- package/.gitignore +3 -0
- package/CHANGELOG.md +14 -0
- package/package.json +11 -5
- package/repo-agent-governance/adapters/claude/contract-loop-gate.ts +27 -0
- package/repo-agent-governance/adapters/codex/contract-loop-gate.mjs +25 -0
- package/repo-agent-governance/adapters/codex/hooks.json +19 -0
- package/repo-agent-governance/adapters/cursor/contract-loop-gate.mjs +28 -0
- package/repo-agent-governance/adapters/cursor/hooks.json +19 -0
- package/{.claude → repo-agent-governance}/commands/release.md +20 -20
- package/repo-agent-governance/managed-assets.json +915 -0
- package/repo-agent-governance/manifest.json +80 -0
- package/{CLAUDE.md → repo-agent-governance/policies/repository.md} +4 -2
- package/repo-agent-governance/scripts/governance.mjs +669 -0
- package/repo-agent-governance/scripts/pack-check.mjs +48 -0
- package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/SKILL.md +9 -2
- package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/references/contract-loop-map.md +4 -4
- package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/references/verification-checklist.md +5 -3
- package/{.claude → repo-agent-governance}/skills/cabloy-master-detail/SKILL.md +1 -1
- package/{.claude → repo-agent-governance}/skills/cabloy-module-removal/SKILL.md +1 -1
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-execution/SKILL.md +1 -1
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-execution/references/execution-protocol.md +8 -8
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-execution/references/status-and-evidence.md +9 -9
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/SKILL.md +2 -2
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/references/repo-aware-discovery.md +7 -7
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/references/repo-specs-document-set.md +30 -30
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +9 -9
- package/{.claude → repo-agent-governance}/skills/cabloy-workflow/SKILL.md +7 -8
- package/{.claude → repo-agent-governance}/skills/cabloy-worktree-environment/SKILL.md +9 -9
- package/{.claude → repo-agent-governance}/skills/cabloy-zova-source-reading/SKILL.md +5 -5
- package/repo-agent-governance/tests/governance.test.mjs +569 -0
- package/repo-agent-governance/tools/contract-loop/check.mjs +71 -0
- package/repo-agent-governance/tools/contract-loop/core.mjs +205 -0
- package/repo-agent-governance/tools/contract-loop/hook-runtime.mjs +367 -0
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +858 -0
- package/{.claude/skills/cabloy-spec-generation/scripts → repo-agent-governance/tools/spec-charts}/generate-implementation-charts.test.mjs +95 -20
- package/repo-docs/.vitepress/config.mjs +1 -0
- package/repo-docs/ai/agent-governance.md +82 -0
- package/repo-docs/ai/cli-to-skill-map.md +3 -3
- package/repo-docs/ai/docs-skills-rules-mapping.md +30 -26
- package/repo-docs/ai/edition-consistency-checklist.md +8 -6
- package/repo-docs/ai/future-skill-roadmap.md +2 -2
- package/repo-docs/ai/introduction.md +7 -6
- package/repo-docs/ai/repo-guidance.md +3 -2
- package/repo-docs/ai/rules-and-config.md +25 -15
- package/repo-docs/ai/skills.md +7 -7
- package/repo-docs/ai/verification.md +2 -1
- package/repo-docs/fullstack/contract-loop-playbook.md +1 -1
- package/repo-docs/fullstack/quickstart.md +1 -1
- package/repo-docs/reference/package-map.md +3 -2
- package/repo-docs/reference/repo-scripts.md +17 -2
- package/scripts/bootstrapAgentGovernance.mjs +81 -0
- package/scripts/init.ts +1 -0
- package/scripts/release.ts +15 -0
- package/scripts/upgrade.ts +66 -7
- package/zova/pnpm-lock.yaml +3 -3
- package/.claude/hooks/contract-loop-gate.ts +0 -421
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/skills/cabloy-spec-generation/scripts/generate-implementation-charts.mjs +0 -385
- /package/{.claude → repo-agent-governance/adapters/claude}/settings.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-backend-scaffold/SKILL.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-backend-scaffold/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-backend-scaffold/references/backend-thread-map.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/references/resource-custom-state-pattern.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-domain-planning/SKILL.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-frontend-scaffold/SKILL.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-frontend-scaffold/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-frontend-scaffold/references/follow-up-checklist.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-frontend-scaffold/references/frontend-thread-map.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/SKILL.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/references/custom-renderer-demo-checklist.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/references/field-update-decision-tree.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/references/follow-up-checklist.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/references/verification-checklist.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-spec-execution/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-workflow/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-workflow/references/cli-strategy.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-workflow/references/edition-detection.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-worktree-environment/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-zova-source-reading/references/analysis-modes.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-zova-source-reading/references/core-reading-paths.md +0 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const TARGET_PATTERNS = [
|
|
5
|
+
['zova/src/module/', '.ts'],
|
|
6
|
+
['zova/src/module/', '.tsx'],
|
|
7
|
+
['zova/src/module/', '.jsx'],
|
|
8
|
+
['zova/src/module-vendor/', '.ts'],
|
|
9
|
+
['zova/src/module-vendor/', '.tsx'],
|
|
10
|
+
['zova/src/module-vendor/', '.jsx'],
|
|
11
|
+
['zova/src/suite/', '.ts'],
|
|
12
|
+
['zova/src/suite/', '.tsx'],
|
|
13
|
+
['zova/src/suite/', '.jsx'],
|
|
14
|
+
['zova/src/suite-vendor/', '.ts'],
|
|
15
|
+
['zova/src/suite-vendor/', '.tsx'],
|
|
16
|
+
['zova/src/suite-vendor/', '.jsx'],
|
|
17
|
+
['vona/src/', '.ts'],
|
|
18
|
+
['vona/src/', '.tsx'],
|
|
19
|
+
['vona/src/', '.jsx'],
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const FORWARD_PATH_MARKERS = ['/controller/', '/dto/', '/entity/'];
|
|
23
|
+
const FORWARD_CONTENT_MARKERS = ['@Web.', '@Api.field', '@Api.body', 'v.openapi(', '@Dto<'];
|
|
24
|
+
const SHARED_REVERSE_VONA_CONTENT_MARKERS = ['ZovaRender.', 'tableActionRow(', 'tableActionBulk('];
|
|
25
|
+
const REVERSE_ZOVA_PATH_MARKERS = ['/src/bean/', '/src/component/', '/src/.metadata/'];
|
|
26
|
+
const REVERSE_ZOVA_CONTENT_MARKERS = [
|
|
27
|
+
"declare module 'zova-module-a-openapi'",
|
|
28
|
+
'IResourceTableActionRowRecord',
|
|
29
|
+
'@TableCell<',
|
|
30
|
+
'@Component(',
|
|
31
|
+
'@Component<',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export const REVERSE_AUTO_SYNC_COMMANDS = [
|
|
35
|
+
{ args: ['run', 'build:zova:admin'], display: 'npm run build:zova:admin' },
|
|
36
|
+
{ args: ['run', 'deps:vona'], display: 'npm run deps:vona' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export const EDITION_CONFIGS = {
|
|
40
|
+
basic: {
|
|
41
|
+
id: 'basic',
|
|
42
|
+
label: 'Cabloy Basic',
|
|
43
|
+
reverseVonaContentMarkers: ['zova-rest-cabloy-basic-admin'],
|
|
44
|
+
reverseAutoSyncCommands: REVERSE_AUTO_SYNC_COMMANDS,
|
|
45
|
+
reverseWebBuildCommand: 'npm run build:zova:web',
|
|
46
|
+
},
|
|
47
|
+
start: {
|
|
48
|
+
id: 'start',
|
|
49
|
+
label: 'Cabloy Start',
|
|
50
|
+
reverseVonaContentMarkers: ['zova-rest-cabloy-start-admin'],
|
|
51
|
+
reverseAutoSyncCommands: REVERSE_AUTO_SYNC_COMMANDS,
|
|
52
|
+
reverseWebBuildCommand: 'npm run build:zova:web',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const FALLBACK_REVERSE_VONA_CONTENT_MARKERS = [
|
|
57
|
+
...SHARED_REVERSE_VONA_CONTENT_MARKERS,
|
|
58
|
+
...EDITION_CONFIGS.basic.reverseVonaContentMarkers,
|
|
59
|
+
...EDITION_CONFIGS.start.reverseVonaContentMarkers,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export function toPosixPath(value) {
|
|
63
|
+
return value.split(path.sep).join('/');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function normalizePath(root, value) {
|
|
67
|
+
if (!value) return null;
|
|
68
|
+
try {
|
|
69
|
+
const candidate = path.isAbsolute(value) ? value : path.join(root, value);
|
|
70
|
+
return toPosixPath(path.resolve(candidate));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveEdition(root) {
|
|
77
|
+
const hasBasic = existsSync(path.resolve(root, '__CABLOY_BASIC__'));
|
|
78
|
+
const hasStart = existsSync(path.resolve(root, '__CABLOY_START__'));
|
|
79
|
+
if (hasBasic && hasStart) return { kind: 'ambiguous' };
|
|
80
|
+
if (!hasBasic && !hasStart) return { kind: 'missing' };
|
|
81
|
+
return { kind: 'resolved', edition: hasBasic ? EDITION_CONFIGS.basic : EDITION_CONFIGS.start };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function containsAny(text, needles) {
|
|
85
|
+
return needles.some(needle => text.includes(needle));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pathContainsAny(filePath, needles) {
|
|
89
|
+
return needles.some(needle => filePath.includes(needle));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getReverseVonaContentMarkers(edition) {
|
|
93
|
+
if (!edition) return FALLBACK_REVERSE_VONA_CONTENT_MARKERS;
|
|
94
|
+
return [...SHARED_REVERSE_VONA_CONTENT_MARKERS, ...edition.reverseVonaContentMarkers];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isCodeFile(filePath) {
|
|
98
|
+
return TARGET_PATTERNS.some(
|
|
99
|
+
([prefix, suffix]) => filePath.includes(prefix) && filePath.endsWith(suffix),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function detectForwardPath(filePath) {
|
|
104
|
+
if (filePath.includes('/vona/src/') && pathContainsAny(filePath, FORWARD_PATH_MARKERS)) {
|
|
105
|
+
return 'Backend contract source may have changed.';
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function detectForwardContent(filePath, content) {
|
|
111
|
+
if (filePath.includes('/vona/src/') && containsAny(content, FORWARD_CONTENT_MARKERS)) {
|
|
112
|
+
return 'Backend contract source may have changed.';
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function detectReversePath(filePath) {
|
|
118
|
+
if (filePath.includes('/zova/src/') && pathContainsAny(filePath, REVERSE_ZOVA_PATH_MARKERS)) {
|
|
119
|
+
return 'Frontend-owned resources or metadata may affect backend consumers.';
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function detectReverseContent(filePath, content, reverseVonaContentMarkers) {
|
|
125
|
+
if (filePath.includes('/vona/src/') && containsAny(content, reverseVonaContentMarkers)) {
|
|
126
|
+
return 'Vona code is consuming frontend metadata or render resources.';
|
|
127
|
+
}
|
|
128
|
+
if (filePath.includes('/zova/src/') && containsAny(content, REVERSE_ZOVA_CONTENT_MARKERS)) {
|
|
129
|
+
return 'Frontend-owned resources or metadata may affect backend consumers.';
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function analyzePath(filePath) {
|
|
135
|
+
return {
|
|
136
|
+
forwardReason: detectForwardPath(filePath),
|
|
137
|
+
reverseReason: detectReversePath(filePath),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function analyze(filePath, content, edition) {
|
|
142
|
+
const pathResult = analyzePath(filePath);
|
|
143
|
+
return {
|
|
144
|
+
forwardReason: pathResult.forwardReason ?? detectForwardContent(filePath, content),
|
|
145
|
+
reverseReason:
|
|
146
|
+
pathResult.reverseReason ??
|
|
147
|
+
detectReverseContent(filePath, content, getReverseVonaContentMarkers(edition)),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function hasSignal(result) {
|
|
152
|
+
return Boolean(result.forwardReason || result.reverseReason);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function isHighConfidenceReverseSource(filePath) {
|
|
156
|
+
return filePath.includes('/zova/src/') && pathContainsAny(filePath, REVERSE_ZOVA_PATH_MARKERS);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function buildReverseGuidance(resolution) {
|
|
160
|
+
if (resolution.kind !== 'resolved') {
|
|
161
|
+
const resolutionMessage =
|
|
162
|
+
resolution.kind === 'ambiguous'
|
|
163
|
+
? 'Both Cabloy edition markers are present, so stop before choosing an edition-specific build or generated-output path.'
|
|
164
|
+
: 'No Cabloy edition marker is present, so inspect the active package scripts and repository shape before choosing an edition-specific build or generated-output path.';
|
|
165
|
+
return `If backend tooling or backend metadata will consume this handoff, refresh generated metadata when applicable. ${resolutionMessage} Then run \`npm run deps:vona\` once the active edition is resolved.`;
|
|
166
|
+
}
|
|
167
|
+
const edition = resolution.edition;
|
|
168
|
+
const autoSyncCommands = edition.reverseAutoSyncCommands
|
|
169
|
+
.map(command => `\`${command.display}\``)
|
|
170
|
+
.join(', then ');
|
|
171
|
+
return `If backend tooling or backend metadata will consume this handoff, refresh generated metadata when applicable, run ${autoSyncCommands}, and also run \`${edition.reverseWebBuildCommand}\` when the Web flavor is affected in this ${edition.label} repo.`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function buildMessages(
|
|
175
|
+
result,
|
|
176
|
+
resolution,
|
|
177
|
+
reverseSyncOutcome = { kind: 'not-applicable', message: '' },
|
|
178
|
+
) {
|
|
179
|
+
const messages = [
|
|
180
|
+
"Contract-loop gate: this change may affect Cabloy's bidirectional contract loop.",
|
|
181
|
+
];
|
|
182
|
+
if (resolution.kind === 'ambiguous') {
|
|
183
|
+
messages.push(
|
|
184
|
+
'Both Cabloy edition markers are present, so stop before choosing an edition-specific build, generated-output path, or auto-sync action.',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (result.forwardReason) {
|
|
188
|
+
messages.push(
|
|
189
|
+
`Forward chain: ${result.forwardReason} If backend contract truth changed, verify the emitted OpenAPI/contract output and regenerate the frontend consumer path before considering the task done.`,
|
|
190
|
+
);
|
|
191
|
+
messages.push(
|
|
192
|
+
'After forward regeneration, keep frontend follow-up thin: prefer semantic model facades and reuse the existing resource-owner when the custom API still belongs to the same resource.',
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (result.reverseReason) {
|
|
196
|
+
messages.push(`Reverse chain: ${result.reverseReason} ${buildReverseGuidance(resolution)}`);
|
|
197
|
+
if (reverseSyncOutcome.message) messages.push(reverseSyncOutcome.message);
|
|
198
|
+
if (reverseSyncOutcome.kind === 'failure') {
|
|
199
|
+
messages.push(
|
|
200
|
+
'Please review the failure before continuing. If generated artifacts already contain the expected changes but consumers still behave stale, suspect local dependency drift before making more source edits.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return messages.join(' ');
|
|
205
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
analyze,
|
|
8
|
+
analyzePath,
|
|
9
|
+
buildMessages,
|
|
10
|
+
isCodeFile,
|
|
11
|
+
isHighConfidenceReverseSource,
|
|
12
|
+
normalizePath,
|
|
13
|
+
resolveEdition,
|
|
14
|
+
toPosixPath,
|
|
15
|
+
} from './core.mjs';
|
|
16
|
+
|
|
17
|
+
const STATE_FILE = path.join(os.tmpdir(), 'cabloy-contract-loop-gate-state.json');
|
|
18
|
+
const AUTO_SYNC_WINDOW_SECONDS = 300;
|
|
19
|
+
|
|
20
|
+
function readText(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
return readFileSync(filePath, 'utf8');
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isDirectory(filePath) {
|
|
29
|
+
try {
|
|
30
|
+
return statSync(filePath).isDirectory();
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function canonicalPath(filePath) {
|
|
37
|
+
let current = filePath;
|
|
38
|
+
const missingSegments = [];
|
|
39
|
+
for (;;) {
|
|
40
|
+
try {
|
|
41
|
+
return path.resolve(realpathSync(current), ...missingSegments);
|
|
42
|
+
} catch {
|
|
43
|
+
const parent = path.dirname(current);
|
|
44
|
+
if (parent === current) return filePath;
|
|
45
|
+
missingSegments.unshift(path.basename(current));
|
|
46
|
+
current = parent;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isInside(root, candidate) {
|
|
52
|
+
const relative = path.relative(canonicalPath(root), canonicalPath(candidate));
|
|
53
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadState() {
|
|
57
|
+
if (!existsSync(STATE_FILE)) return {};
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function saveState(state) {
|
|
66
|
+
try {
|
|
67
|
+
writeFileSync(STATE_FILE, JSON.stringify(state), 'utf8');
|
|
68
|
+
} catch {
|
|
69
|
+
// State prevents duplicate convenience syncs only; it must not block guidance.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function syncFingerprint(filePath) {
|
|
74
|
+
try {
|
|
75
|
+
const stats = statSync(filePath, { bigint: true });
|
|
76
|
+
return `${filePath}:${stats.mtimeNs.toString()}`;
|
|
77
|
+
} catch {
|
|
78
|
+
return filePath;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function shouldSkipAutoSync(root, filePath) {
|
|
83
|
+
const state = loadState();
|
|
84
|
+
const entry = state[toPosixPath(root)];
|
|
85
|
+
return Boolean(
|
|
86
|
+
entry &&
|
|
87
|
+
entry.fingerprint === syncFingerprint(filePath) &&
|
|
88
|
+
Date.now() - entry.timestamp <= AUTO_SYNC_WINDOW_SECONDS * 1000,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function markAutoSync(root, filePath) {
|
|
93
|
+
const state = loadState();
|
|
94
|
+
state[toPosixPath(root)] = { fingerprint: syncFingerprint(filePath), timestamp: Date.now() };
|
|
95
|
+
saveState(state);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function runNpm(root, args) {
|
|
99
|
+
const command = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
100
|
+
return spawnSync(command, [...args], { cwd: root, encoding: 'utf8' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function summarizeProcess(result) {
|
|
104
|
+
if (result.error) return result.error.message;
|
|
105
|
+
const combined = [result.stdout?.trim(), result.stderr?.trim()].filter(Boolean).join('\n');
|
|
106
|
+
const exitCode = result.status ?? 1;
|
|
107
|
+
if (!combined) return `command exited with code ${exitCode}`;
|
|
108
|
+
return `exit ${exitCode}: ${combined
|
|
109
|
+
.split(/\r?\n/)
|
|
110
|
+
.map(line => line.trim())
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.slice(-3)
|
|
113
|
+
.join(' | ')}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function autoSyncReverse(root, filePath, edition) {
|
|
117
|
+
for (const command of edition.reverseAutoSyncCommands) {
|
|
118
|
+
const result = runNpm(root, command.args);
|
|
119
|
+
if (result.status !== 0) {
|
|
120
|
+
return {
|
|
121
|
+
kind: 'failure',
|
|
122
|
+
message: `Auto-sync failed during \`${command.display}\`: ${summarizeProcess(result)}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
markAutoSync(root, filePath);
|
|
127
|
+
return {
|
|
128
|
+
kind: 'success',
|
|
129
|
+
message: `Auto-sync ran ${edition.reverseAutoSyncCommands
|
|
130
|
+
.map(command => `\`${command.display}\``)
|
|
131
|
+
.join(' and ')} for this ${edition.label} reverse-chain edit.`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function resolveReverseSyncOutcome(root, filePath, reverseReason) {
|
|
136
|
+
if (!reverseReason) return { kind: 'not-applicable', message: '' };
|
|
137
|
+
if (!isHighConfidenceReverseSource(filePath)) {
|
|
138
|
+
return {
|
|
139
|
+
kind: 'not-applicable',
|
|
140
|
+
message:
|
|
141
|
+
'Auto-sync did not run because this reverse-chain signal came from the consumer side rather than a high-confidence frontend source edit.',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const resolution = resolveEdition(root);
|
|
145
|
+
if (resolution.kind !== 'resolved') {
|
|
146
|
+
return {
|
|
147
|
+
kind: 'not-applicable',
|
|
148
|
+
message: `Auto-sync did not run because ${resolution.kind === 'ambiguous' ? 'both Cabloy edition markers are present' : 'no Cabloy edition marker is present'}.`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (shouldSkipAutoSync(root, filePath)) {
|
|
152
|
+
return {
|
|
153
|
+
kind: 'skipped',
|
|
154
|
+
message:
|
|
155
|
+
'Auto-sync skipped because the same reverse-source edit was already synced recently in this repo.',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return autoSyncReverse(root, filePath, resolution.edition);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function readHookPayload() {
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(readFileSync(0, 'utf8'));
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function extractClaudeEditedFilePath(payload) {
|
|
170
|
+
const filePath = payload?.tool_input?.file_path;
|
|
171
|
+
return typeof filePath === 'string' && filePath ? filePath : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function extractCodexEditedFiles(payload) {
|
|
175
|
+
const command = payload?.tool_input?.command;
|
|
176
|
+
if (typeof command !== 'string') return [];
|
|
177
|
+
const entries = [];
|
|
178
|
+
let pendingUpdate = null;
|
|
179
|
+
const headerPattern =
|
|
180
|
+
/^\*\*\* (Add|Update|Delete) File:\s*(.+?)\s*$|^\*\*\* Move to:\s*(.+?)\s*$/gm;
|
|
181
|
+
|
|
182
|
+
const flushUpdate = () => {
|
|
183
|
+
if (pendingUpdate) entries.push(pendingUpdate);
|
|
184
|
+
pendingUpdate = null;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
for (const match of command.matchAll(headerPattern)) {
|
|
188
|
+
const [, verb, sourcePath, destinationPath] = match;
|
|
189
|
+
if (destinationPath !== undefined) {
|
|
190
|
+
if (pendingUpdate) pendingUpdate.filePath = destinationPath.trim();
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
flushUpdate();
|
|
194
|
+
const filePath = sourcePath.trim();
|
|
195
|
+
if (!filePath) continue;
|
|
196
|
+
const operation = verb.toLowerCase();
|
|
197
|
+
if (operation === 'update') {
|
|
198
|
+
pendingUpdate = { operation, filePath };
|
|
199
|
+
} else {
|
|
200
|
+
entries.push({ operation, filePath });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
flushUpdate();
|
|
204
|
+
return entries;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Retain the path-only export for callers that only need the historical view.
|
|
208
|
+
export function extractCodexEditedFilePaths(payload) {
|
|
209
|
+
return extractCodexEditedFiles(payload).map(entry => entry.filePath);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function extractCursorEditedFilePath(payload) {
|
|
213
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
214
|
+
if (typeof payload.file_path === 'string' && payload.file_path) return payload.file_path;
|
|
215
|
+
const input = payload.tool_input;
|
|
216
|
+
if (!input || typeof input !== 'object') return null;
|
|
217
|
+
for (const key of ['path', 'file_path', 'target_file']) {
|
|
218
|
+
const value = input[key];
|
|
219
|
+
if (typeof value === 'string' && value) return value;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function inferCursorHookEvent(payload) {
|
|
225
|
+
const eventName = payload?.hook_event_name;
|
|
226
|
+
if (
|
|
227
|
+
eventName === 'afterFileEdit' ||
|
|
228
|
+
eventName === 'afterTabFileEdit' ||
|
|
229
|
+
eventName === 'postToolUse'
|
|
230
|
+
) {
|
|
231
|
+
return eventName;
|
|
232
|
+
}
|
|
233
|
+
if (typeof payload?.file_path === 'string' && payload.file_path && !payload.tool_name) {
|
|
234
|
+
return 'afterFileEdit';
|
|
235
|
+
}
|
|
236
|
+
return 'postToolUse';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatClaudeHookOutput(message) {
|
|
240
|
+
return {
|
|
241
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: message },
|
|
242
|
+
systemMessage: message,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function formatCodexHookOutput(message) {
|
|
247
|
+
return {
|
|
248
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: message },
|
|
249
|
+
systemMessage: message,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function formatCursorHookOutput(message, eventName) {
|
|
254
|
+
if (eventName !== 'postToolUse') return null;
|
|
255
|
+
return { additional_context: message };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function resolvePathBase(root, candidate) {
|
|
259
|
+
if (typeof candidate !== 'string' || !candidate) return root;
|
|
260
|
+
const pathBase = normalizePath(root, candidate);
|
|
261
|
+
if (!pathBase || !isInside(root, pathBase) || !isDirectory(pathBase)) return root;
|
|
262
|
+
return pathBase;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function createInspectionFailure(root, filePath, operation) {
|
|
266
|
+
const relativePath = toPosixPath(path.relative(root, filePath));
|
|
267
|
+
return {
|
|
268
|
+
kind: 'failure',
|
|
269
|
+
filePath,
|
|
270
|
+
message: `Contract-loop gate: could not inspect edited source \`${relativePath}\` after this ${operation} patch; no contract-loop guidance or auto-sync ran.`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function inspectEditedFile(root, rawPath, { operation = 'update', pathBase = root } = {}) {
|
|
275
|
+
const filePath = normalizePath(pathBase, rawPath);
|
|
276
|
+
if (!filePath || !isInside(root, filePath) || !isCodeFile(filePath)) return null;
|
|
277
|
+
|
|
278
|
+
const resolution = resolveEdition(root);
|
|
279
|
+
if (operation === 'delete') {
|
|
280
|
+
const result = analyzePath(filePath);
|
|
281
|
+
if (!result.forwardReason && !result.reverseReason) return null;
|
|
282
|
+
return { kind: 'signal', filePath, resolution, result };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const content = readText(filePath);
|
|
286
|
+
if (content === null) return createInspectionFailure(root, filePath, operation);
|
|
287
|
+
const result = analyze(
|
|
288
|
+
filePath,
|
|
289
|
+
content,
|
|
290
|
+
resolution.kind === 'resolved' ? resolution.edition : null,
|
|
291
|
+
);
|
|
292
|
+
if (!result.forwardReason && !result.reverseReason) return null;
|
|
293
|
+
return { kind: 'signal', filePath, resolution, result };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function evaluateEditedFile(root, rawPath) {
|
|
297
|
+
const inspected = inspectEditedFile(root, rawPath);
|
|
298
|
+
if (!inspected || inspected.kind !== 'signal') return null;
|
|
299
|
+
const { filePath, resolution, result } = inspected;
|
|
300
|
+
const reverseSyncOutcome = resolveReverseSyncOutcome(root, filePath, result.reverseReason);
|
|
301
|
+
return {
|
|
302
|
+
filePath,
|
|
303
|
+
message: buildMessages(result, resolution, reverseSyncOutcome),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function evaluateCodexEditedFiles(root, entries, payloadCwd) {
|
|
308
|
+
const pathBase = resolvePathBase(root, payloadCwd);
|
|
309
|
+
const seenEntries = new Set();
|
|
310
|
+
const inspected = [];
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
if (!entry || typeof entry.filePath !== 'string') continue;
|
|
313
|
+
const operation = ['add', 'update', 'delete'].includes(entry.operation)
|
|
314
|
+
? entry.operation
|
|
315
|
+
: 'update';
|
|
316
|
+
const normalizedPath = normalizePath(pathBase, entry.filePath);
|
|
317
|
+
if (!normalizedPath || !isInside(root, normalizedPath)) continue;
|
|
318
|
+
const key = `${operation}:${normalizedPath}`;
|
|
319
|
+
if (seenEntries.has(key)) continue;
|
|
320
|
+
seenEntries.add(key);
|
|
321
|
+
const item = inspectEditedFile(root, entry.filePath, { operation, pathBase });
|
|
322
|
+
if (item) inspected.push(item);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const autoSyncItem = inspected.find(
|
|
326
|
+
item =>
|
|
327
|
+
item.kind === 'signal' &&
|
|
328
|
+
item.result.reverseReason &&
|
|
329
|
+
isHighConfidenceReverseSource(item.filePath),
|
|
330
|
+
);
|
|
331
|
+
const autoSyncOutcome = autoSyncItem
|
|
332
|
+
? resolveReverseSyncOutcome(root, autoSyncItem.filePath, autoSyncItem.result.reverseReason)
|
|
333
|
+
: null;
|
|
334
|
+
|
|
335
|
+
return inspected.map(item => {
|
|
336
|
+
if (item.kind === 'failure') return { filePath: item.filePath, message: item.message };
|
|
337
|
+
|
|
338
|
+
let reverseSyncOutcome;
|
|
339
|
+
if (item === autoSyncItem) {
|
|
340
|
+
reverseSyncOutcome = autoSyncOutcome;
|
|
341
|
+
} else if (item.result.reverseReason && isHighConfidenceReverseSource(item.filePath)) {
|
|
342
|
+
reverseSyncOutcome = {
|
|
343
|
+
kind: 'skipped',
|
|
344
|
+
message:
|
|
345
|
+
'Auto-sync skipped because another high-confidence reverse-source file in this Codex edit was already handled.',
|
|
346
|
+
};
|
|
347
|
+
} else {
|
|
348
|
+
reverseSyncOutcome = resolveReverseSyncOutcome(
|
|
349
|
+
root,
|
|
350
|
+
item.filePath,
|
|
351
|
+
item.result.reverseReason,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
filePath: item.filePath,
|
|
356
|
+
message: buildMessages(item.result, item.resolution, reverseSyncOutcome),
|
|
357
|
+
};
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Keep the prior general API for direct callers. Codex now uses the operation-aware evaluator.
|
|
362
|
+
export function evaluateEditedFiles(root, rawPaths) {
|
|
363
|
+
return evaluateCodexEditedFiles(
|
|
364
|
+
root,
|
|
365
|
+
rawPaths.map(filePath => ({ operation: 'update', filePath })),
|
|
366
|
+
);
|
|
367
|
+
}
|