agentxchain 0.8.8 → 2.1.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/README.md +126 -142
- package/bin/agentxchain.js +186 -5
- package/dashboard/app.js +305 -0
- package/dashboard/components/blocked.js +145 -0
- package/dashboard/components/cross-repo.js +126 -0
- package/dashboard/components/gate.js +311 -0
- package/dashboard/components/hooks.js +177 -0
- package/dashboard/components/initiative.js +147 -0
- package/dashboard/components/ledger.js +165 -0
- package/dashboard/components/timeline.js +222 -0
- package/dashboard/index.html +352 -0
- package/package.json +14 -6
- package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
- package/scripts/publish-from-tag.sh +88 -0
- package/scripts/release-postflight.sh +231 -0
- package/scripts/release-preflight.sh +167 -0
- package/src/commands/accept-turn.js +160 -0
- package/src/commands/approve-completion.js +80 -0
- package/src/commands/approve-transition.js +85 -0
- package/src/commands/dashboard.js +70 -0
- package/src/commands/init.js +516 -0
- package/src/commands/migrate.js +348 -0
- package/src/commands/multi.js +549 -0
- package/src/commands/plugin.js +157 -0
- package/src/commands/reject-turn.js +204 -0
- package/src/commands/resume.js +389 -0
- package/src/commands/status.js +196 -3
- package/src/commands/step.js +947 -0
- package/src/commands/template-list.js +33 -0
- package/src/commands/template-set.js +279 -0
- package/src/commands/validate.js +20 -11
- package/src/commands/verify.js +71 -0
- package/src/lib/adapters/api-proxy-adapter.js +1076 -0
- package/src/lib/adapters/local-cli-adapter.js +337 -0
- package/src/lib/adapters/manual-adapter.js +169 -0
- package/src/lib/blocked-state.js +94 -0
- package/src/lib/config.js +97 -1
- package/src/lib/context-compressor.js +121 -0
- package/src/lib/context-section-parser.js +220 -0
- package/src/lib/coordinator-acceptance.js +428 -0
- package/src/lib/coordinator-config.js +461 -0
- package/src/lib/coordinator-dispatch.js +276 -0
- package/src/lib/coordinator-gates.js +487 -0
- package/src/lib/coordinator-hooks.js +239 -0
- package/src/lib/coordinator-recovery.js +523 -0
- package/src/lib/coordinator-state.js +365 -0
- package/src/lib/cross-repo-context.js +247 -0
- package/src/lib/dashboard/bridge-server.js +284 -0
- package/src/lib/dashboard/file-watcher.js +93 -0
- package/src/lib/dashboard/state-reader.js +96 -0
- package/src/lib/dispatch-bundle.js +568 -0
- package/src/lib/dispatch-manifest.js +252 -0
- package/src/lib/gate-evaluator.js +285 -0
- package/src/lib/governed-state.js +2139 -0
- package/src/lib/governed-templates.js +145 -0
- package/src/lib/hook-runner.js +788 -0
- package/src/lib/normalized-config.js +539 -0
- package/src/lib/plugin-config-schema.js +192 -0
- package/src/lib/plugins.js +692 -0
- package/src/lib/protocol-conformance.js +291 -0
- package/src/lib/reference-conformance-adapter.js +717 -0
- package/src/lib/repo-observer.js +597 -0
- package/src/lib/repo.js +0 -31
- package/src/lib/schema.js +121 -0
- package/src/lib/schemas/turn-result.schema.json +205 -0
- package/src/lib/token-budget.js +206 -0
- package/src/lib/token-counter.js +27 -0
- package/src/lib/turn-paths.js +67 -0
- package/src/lib/turn-result-validator.js +496 -0
- package/src/lib/validation.js +137 -0
- package/src/templates/governed/api-service.json +31 -0
- package/src/templates/governed/cli-tool.json +30 -0
- package/src/templates/governed/generic.json +10 -0
- package/src/templates/governed/web-app.json +30 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo observation utilities — orchestrator-derived artifact truth.
|
|
3
|
+
*
|
|
4
|
+
* These functions give the orchestrator an independent view of what actually
|
|
5
|
+
* changed in the repo, instead of trusting agent self-reporting.
|
|
6
|
+
*
|
|
7
|
+
* Design rules (§15, Session #15 decision freezes):
|
|
8
|
+
* - For workspace and review artifacts, the orchestrator is the source
|
|
9
|
+
* of truth for what actually changed and what ref is accepted.
|
|
10
|
+
* - Baseline is captured at assignment time; observed diff is computed
|
|
11
|
+
* at acceptance time.
|
|
12
|
+
* - accepted_integration_ref is always derived from orchestrator observation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execSync } from 'child_process';
|
|
16
|
+
import { createHash } from 'crypto';
|
|
17
|
+
import { existsSync, readFileSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
|
|
20
|
+
// ── Orchestrator-Owned Operational Paths ────────────────────────────────────
|
|
21
|
+
// These paths are written by the orchestrator during dispatch/accept cycles.
|
|
22
|
+
// They must never be attributed to agents in observation or baseline checks.
|
|
23
|
+
// Frozen per Session #19 decision.
|
|
24
|
+
|
|
25
|
+
const OPERATIONAL_PATH_PREFIXES = [
|
|
26
|
+
'.agentxchain/dispatch/',
|
|
27
|
+
'.agentxchain/staging/',
|
|
28
|
+
'.agentxchain/locks/',
|
|
29
|
+
'.agentxchain/transactions/',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Orchestrator-owned state files that agents must never be blamed for modifying.
|
|
33
|
+
// These are written exclusively by the orchestrator (§4.1 State Ownership Rule).
|
|
34
|
+
const ORCHESTRATOR_STATE_FILES = [
|
|
35
|
+
'.agentxchain/state.json',
|
|
36
|
+
'.agentxchain/history.jsonl',
|
|
37
|
+
'.agentxchain/decision-ledger.jsonl',
|
|
38
|
+
'.agentxchain/lock.json',
|
|
39
|
+
'.agentxchain/hook-audit.jsonl',
|
|
40
|
+
'.agentxchain/hook-annotations.jsonl',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check whether a file path belongs to orchestrator-owned operational state.
|
|
45
|
+
* These paths are excluded from actor-attributed observation.
|
|
46
|
+
*/
|
|
47
|
+
export function isOperationalPath(filePath) {
|
|
48
|
+
return OPERATIONAL_PATH_PREFIXES.some(prefix => filePath.startsWith(prefix))
|
|
49
|
+
|| ORCHESTRATOR_STATE_FILES.includes(filePath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Baseline Capture ────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Capture a baseline snapshot of the repo at turn assignment time.
|
|
56
|
+
* This gives acceptance a stable "before" view.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} root — project root directory
|
|
59
|
+
* @returns {{ kind: string, head_ref: string|null, clean: boolean, captured_at: string }}
|
|
60
|
+
*/
|
|
61
|
+
export function captureBaseline(root) {
|
|
62
|
+
const now = new Date().toISOString();
|
|
63
|
+
|
|
64
|
+
if (!isGitRepo(root)) {
|
|
65
|
+
return {
|
|
66
|
+
kind: 'no_git',
|
|
67
|
+
head_ref: null,
|
|
68
|
+
clean: true,
|
|
69
|
+
captured_at: now,
|
|
70
|
+
dirty_snapshot: {},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const headRef = getHeadRef(root);
|
|
75
|
+
const clean = isWorkingTreeClean(root);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
kind: 'git_worktree',
|
|
79
|
+
head_ref: headRef,
|
|
80
|
+
clean,
|
|
81
|
+
captured_at: now,
|
|
82
|
+
dirty_snapshot: clean ? {} : captureDirtyWorkspaceSnapshot(root),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Observed Diff ───────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compute the set of files that actually changed since the baseline.
|
|
90
|
+
* Uses git diff against the baseline HEAD ref, plus any untracked files.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} root — project root directory
|
|
93
|
+
* @param {object} baseline — the baseline captured at assignment time
|
|
94
|
+
* @returns {{ files_changed: string[], head_ref: string|null, diff_summary: string|null }}
|
|
95
|
+
*/
|
|
96
|
+
export function observeChanges(root, baseline) {
|
|
97
|
+
if (!isGitRepo(root) || (baseline && baseline.kind === 'no_git')) {
|
|
98
|
+
// Non-git project — no observation possible
|
|
99
|
+
return { files_changed: [], head_ref: null, diff_summary: null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const currentHead = getHeadRef(root);
|
|
103
|
+
const untrackedFiles = getUntrackedFiles(root);
|
|
104
|
+
|
|
105
|
+
// Strategy: compare against baseline head_ref if available,
|
|
106
|
+
// otherwise detect all uncommitted changes (staged + unstaged + untracked)
|
|
107
|
+
let changedFiles = [];
|
|
108
|
+
let diffSummary = null;
|
|
109
|
+
|
|
110
|
+
if (baseline?.head_ref && baseline.head_ref === currentHead) {
|
|
111
|
+
// Same commit — changes are in working tree / staging area
|
|
112
|
+
changedFiles = getWorkingTreeChanges(root);
|
|
113
|
+
changedFiles = filterBaselineDirtyFiles(root, changedFiles, baseline);
|
|
114
|
+
diffSummary = buildObservedDiffSummary(getWorkingTreeDiffSummary(root), untrackedFiles);
|
|
115
|
+
} else if (baseline?.head_ref) {
|
|
116
|
+
// New commits exist — get files changed since baseline ref
|
|
117
|
+
changedFiles = getCommittedChanges(root, baseline.head_ref);
|
|
118
|
+
// Also include any uncommitted working tree changes
|
|
119
|
+
const workingChanges = getWorkingTreeChanges(root);
|
|
120
|
+
for (const f of workingChanges) {
|
|
121
|
+
if (!changedFiles.includes(f)) changedFiles.push(f);
|
|
122
|
+
}
|
|
123
|
+
diffSummary = buildObservedDiffSummary(getDiffSummary(root, baseline.head_ref), untrackedFiles);
|
|
124
|
+
} else {
|
|
125
|
+
// No baseline ref — fall back to working tree changes only
|
|
126
|
+
changedFiles = getWorkingTreeChanges(root);
|
|
127
|
+
diffSummary = buildObservedDiffSummary(getWorkingTreeDiffSummary(root), untrackedFiles);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Filter out orchestrator-owned operational paths (Session #19 freeze)
|
|
131
|
+
const actorFiles = changedFiles.filter(f => !isOperationalPath(f));
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
files_changed: actorFiles.sort(),
|
|
135
|
+
head_ref: currentHead,
|
|
136
|
+
diff_summary: diffSummary,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Classify observed file changes into added, modified, and deleted.
|
|
142
|
+
*
|
|
143
|
+
* Uses git diff-filter when a baseline ref is available; falls back to
|
|
144
|
+
* heuristic classification (untracked → added, missing → deleted, else modified)
|
|
145
|
+
* when working from working-tree-only observation.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} root — project root
|
|
148
|
+
* @param {object} observation — from observeChanges()
|
|
149
|
+
* @param {object} baseline — from captureBaseline()
|
|
150
|
+
* @returns {{ added: string[], modified: string[], deleted: string[] }}
|
|
151
|
+
*/
|
|
152
|
+
export function classifyObservedChanges(root, observation, baseline) {
|
|
153
|
+
const files = observation.files_changed || [];
|
|
154
|
+
if (files.length === 0) {
|
|
155
|
+
return { added: [], modified: [], deleted: [] };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// If we have a baseline ref, use git diff-filter for accurate classification
|
|
159
|
+
if (baseline?.head_ref && isGitRepo(root)) {
|
|
160
|
+
const added = new Set();
|
|
161
|
+
const modified = new Set();
|
|
162
|
+
const deleted = new Set();
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const diffAdded = getFilteredChanges(root, baseline.head_ref, 'A');
|
|
166
|
+
const diffModified = getFilteredChanges(root, baseline.head_ref, 'M');
|
|
167
|
+
const diffDeleted = getFilteredChanges(root, baseline.head_ref, 'D');
|
|
168
|
+
|
|
169
|
+
for (const f of diffAdded) added.add(f);
|
|
170
|
+
for (const f of diffModified) modified.add(f);
|
|
171
|
+
for (const f of diffDeleted) deleted.add(f);
|
|
172
|
+
} catch {
|
|
173
|
+
// Fall through to heuristic
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Untracked files are always "added"
|
|
177
|
+
try {
|
|
178
|
+
const untracked = getUntrackedFiles(root);
|
|
179
|
+
for (const f of untracked) {
|
|
180
|
+
if (files.includes(f)) added.add(f);
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// Ignore
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Working tree changes not in the committed diff — use heuristic
|
|
187
|
+
for (const f of files) {
|
|
188
|
+
if (!added.has(f) && !modified.has(f) && !deleted.has(f)) {
|
|
189
|
+
if (existsSync(join(root, f))) {
|
|
190
|
+
modified.add(f);
|
|
191
|
+
} else {
|
|
192
|
+
deleted.add(f);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const fileSet = new Set(files);
|
|
198
|
+
return {
|
|
199
|
+
added: [...added].filter(f => fileSet.has(f)).sort(),
|
|
200
|
+
modified: [...modified].filter(f => fileSet.has(f)).sort(),
|
|
201
|
+
deleted: [...deleted].filter(f => fileSet.has(f)).sort(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// No baseline ref — heuristic classification
|
|
206
|
+
const added = [];
|
|
207
|
+
const modified = [];
|
|
208
|
+
const deleted = [];
|
|
209
|
+
|
|
210
|
+
for (const f of files) {
|
|
211
|
+
if (!existsSync(join(root, f))) {
|
|
212
|
+
deleted.push(f);
|
|
213
|
+
} else {
|
|
214
|
+
try {
|
|
215
|
+
execSync(`git ls-files --error-unmatch -- "${f}"`, {
|
|
216
|
+
cwd: root,
|
|
217
|
+
encoding: 'utf8',
|
|
218
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
219
|
+
});
|
|
220
|
+
modified.push(f);
|
|
221
|
+
} catch {
|
|
222
|
+
added.push(f);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { added: added.sort(), modified: modified.sort(), deleted: deleted.sort() };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Get files matching a specific diff-filter from baseline ref to HEAD/working tree.
|
|
232
|
+
*/
|
|
233
|
+
function getFilteredChanges(root, baseRef, filter) {
|
|
234
|
+
try {
|
|
235
|
+
const result = execSync(`git diff --name-only --diff-filter=${filter} ${baseRef}`, {
|
|
236
|
+
cwd: root,
|
|
237
|
+
encoding: 'utf8',
|
|
238
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
239
|
+
}).trim();
|
|
240
|
+
return result ? result.split('\n').filter(Boolean) : [];
|
|
241
|
+
} catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Build the orchestrator-derived observed artifact record.
|
|
248
|
+
* This is what gets stored in history, not the actor's self-report.
|
|
249
|
+
*
|
|
250
|
+
* @param {object} observation — from observeChanges()
|
|
251
|
+
* @param {object} baseline — from captureBaseline()
|
|
252
|
+
* @returns {object}
|
|
253
|
+
*/
|
|
254
|
+
export function buildObservedArtifact(observation, baseline) {
|
|
255
|
+
return {
|
|
256
|
+
derived_by: 'orchestrator',
|
|
257
|
+
baseline_ref: baseline?.head_ref ? `git:${baseline.head_ref}` : null,
|
|
258
|
+
accepted_ref: observation.head_ref ? `git:${observation.head_ref}` : 'workspace:dirty',
|
|
259
|
+
files_changed: observation.files_changed,
|
|
260
|
+
diff_summary: observation.diff_summary,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Verification Normalization ──────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Normalize the actor-supplied verification status based on runtime type
|
|
268
|
+
* and evidence quality.
|
|
269
|
+
*
|
|
270
|
+
* Normalization rules (spec §5.3):
|
|
271
|
+
* - manual + external pass → attested_pass
|
|
272
|
+
* - local_cli + pass + machine evidence all zero → pass
|
|
273
|
+
* - local_cli + pass + no reproducible evidence → not_reproducible
|
|
274
|
+
* - any external fail → fail
|
|
275
|
+
* - external skipped → skipped
|
|
276
|
+
*
|
|
277
|
+
* @param {object} verification — the actor-supplied verification object
|
|
278
|
+
* @param {string} runtimeType — 'manual' | 'local_cli' | 'api_proxy'
|
|
279
|
+
* @returns {{ status: string, reason: string, reproducible: boolean }}
|
|
280
|
+
*/
|
|
281
|
+
export function normalizeVerification(verification, runtimeType) {
|
|
282
|
+
const externalStatus = verification?.status || 'skipped';
|
|
283
|
+
|
|
284
|
+
if (externalStatus === 'fail') {
|
|
285
|
+
return { status: 'fail', reason: 'Agent reported verification failure', reproducible: false };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (externalStatus === 'skipped') {
|
|
289
|
+
return { status: 'skipped', reason: 'Agent skipped verification', reproducible: false };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// externalStatus === 'pass'
|
|
293
|
+
if (runtimeType === 'manual') {
|
|
294
|
+
return { status: 'attested_pass', reason: 'Manual runtime — human attested pass', reproducible: false };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (runtimeType === 'api_proxy') {
|
|
298
|
+
return { status: 'attested_pass', reason: 'API proxy runtime — no direct execution environment', reproducible: false };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// local_cli — check for machine evidence
|
|
302
|
+
const evidence = verification?.machine_evidence;
|
|
303
|
+
if (Array.isArray(evidence) && evidence.length > 0) {
|
|
304
|
+
const allZero = evidence.every(e => typeof e.exit_code === 'number' && e.exit_code === 0);
|
|
305
|
+
if (allZero) {
|
|
306
|
+
return { status: 'pass', reason: 'local_cli turn provided machine evidence with zero exit codes', reproducible: true };
|
|
307
|
+
}
|
|
308
|
+
return { status: 'not_reproducible', reason: 'local_cli turn has machine evidence with non-zero exit codes despite claiming pass', reproducible: false };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// local_cli + pass but no machine evidence
|
|
312
|
+
return { status: 'not_reproducible', reason: 'local_cli turn claimed pass but provided no machine evidence', reproducible: false };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Declared vs Observed Comparison ─────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Compare declared files_changed against observed files_changed.
|
|
319
|
+
* Returns errors for mismatches.
|
|
320
|
+
*
|
|
321
|
+
* @param {string[]} declared — files_changed from the turn result
|
|
322
|
+
* @param {string[]} observed — files_changed from observeChanges()
|
|
323
|
+
* @param {string} writeAuthority — 'authoritative' | 'proposed' | 'review_only'
|
|
324
|
+
* @returns {{ errors: string[], warnings: string[] }}
|
|
325
|
+
*/
|
|
326
|
+
export function compareDeclaredVsObserved(declared, observed, writeAuthority) {
|
|
327
|
+
const errors = [];
|
|
328
|
+
const warnings = [];
|
|
329
|
+
|
|
330
|
+
const declaredSet = new Set(declared || []);
|
|
331
|
+
const observedSet = new Set(observed || []);
|
|
332
|
+
|
|
333
|
+
// Files the agent changed but didn't declare
|
|
334
|
+
const undeclared = [...observedSet].filter(f => !declaredSet.has(f));
|
|
335
|
+
// Files the agent declared but didn't actually change
|
|
336
|
+
const phantom = [...declaredSet].filter(f => !observedSet.has(f));
|
|
337
|
+
|
|
338
|
+
if (writeAuthority === 'authoritative') {
|
|
339
|
+
if (undeclared.length > 0) {
|
|
340
|
+
errors.push(`Undeclared file changes detected (observed but not in files_changed): ${undeclared.join(', ')}`);
|
|
341
|
+
}
|
|
342
|
+
if (phantom.length > 0) {
|
|
343
|
+
warnings.push(`Declared files not observed in actual diff: ${phantom.join(', ')}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (writeAuthority === 'review_only') {
|
|
348
|
+
// Review-only roles must not touch product files, even if undeclared
|
|
349
|
+
const productFileChanges = observed.filter(f => !isAllowedReviewPath(f));
|
|
350
|
+
if (productFileChanges.length > 0) {
|
|
351
|
+
errors.push(`review_only role modified product files (observed in actual diff): ${productFileChanges.join(', ')}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return { errors, warnings };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Integration Ref Derivation ──────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Derive the accepted_integration_ref from orchestrator observation.
|
|
362
|
+
* Never copied from actor JSON for workspace or review artifacts.
|
|
363
|
+
*
|
|
364
|
+
* @param {object} observation — from observeChanges()
|
|
365
|
+
* @param {string} artifactType — 'workspace' | 'patch' | 'commit' | 'review'
|
|
366
|
+
* @param {string|null} currentRef — current accepted_integration_ref from state
|
|
367
|
+
* @returns {string}
|
|
368
|
+
*/
|
|
369
|
+
export function deriveAcceptedRef(observation, artifactType, currentRef) {
|
|
370
|
+
if (artifactType === 'workspace' || artifactType === 'review') {
|
|
371
|
+
// Always derive from observed state
|
|
372
|
+
if (observation.head_ref) {
|
|
373
|
+
return `git:${observation.head_ref}`;
|
|
374
|
+
}
|
|
375
|
+
return 'workspace:dirty';
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// For patch/commit, the ref was validated during artifact application
|
|
379
|
+
// but we still prefer the observed head
|
|
380
|
+
if (observation.head_ref) {
|
|
381
|
+
return `git:${observation.head_ref}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return currentRef || 'unknown';
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Clean Baseline Check ────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Check if the repo is clean enough for a code-writing turn.
|
|
391
|
+
*
|
|
392
|
+
* v1 rule: before assigning an authoritative or proposed turn,
|
|
393
|
+
* the repo must be clean relative to the current accepted integration ref.
|
|
394
|
+
*
|
|
395
|
+
* @param {string} root — project root directory
|
|
396
|
+
* @param {string} writeAuthority — 'authoritative' | 'proposed' | 'review_only'
|
|
397
|
+
* @returns {{ clean: boolean, reason?: string }}
|
|
398
|
+
*/
|
|
399
|
+
export function checkCleanBaseline(root, writeAuthority) {
|
|
400
|
+
if (writeAuthority === 'review_only') {
|
|
401
|
+
return { clean: true };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!isGitRepo(root)) {
|
|
405
|
+
// Non-git projects skip the clean baseline check
|
|
406
|
+
return { clean: true };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Check if all dirty files are orchestrator-owned operational paths.
|
|
410
|
+
// If only operational paths are dirty, the baseline is still clean for actor purposes.
|
|
411
|
+
const dirtyFiles = getWorkingTreeChanges(root);
|
|
412
|
+
const actorDirtyFiles = dirtyFiles.filter(f => !isOperationalPath(f));
|
|
413
|
+
|
|
414
|
+
if (actorDirtyFiles.length === 0) return { clean: true };
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
clean: false,
|
|
418
|
+
reason: `Working tree has uncommitted changes in actor-owned files: ${actorDirtyFiles.slice(0, 5).join(', ')}${actorDirtyFiles.length > 5 ? '...' : ''}. Authoritative/proposed turns require a clean baseline in v1. Commit or stash those changes before assigning the next code-writing turn.`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── Git Primitives ──────────────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
function isGitRepo(root) {
|
|
425
|
+
try {
|
|
426
|
+
execSync('git rev-parse --is-inside-work-tree', {
|
|
427
|
+
cwd: root,
|
|
428
|
+
encoding: 'utf8',
|
|
429
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
430
|
+
});
|
|
431
|
+
return true;
|
|
432
|
+
} catch {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function getHeadRef(root) {
|
|
438
|
+
try {
|
|
439
|
+
return execSync('git rev-parse HEAD', {
|
|
440
|
+
cwd: root,
|
|
441
|
+
encoding: 'utf8',
|
|
442
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
443
|
+
}).trim();
|
|
444
|
+
} catch {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function isWorkingTreeClean(root) {
|
|
450
|
+
try {
|
|
451
|
+
const status = execSync('git status --porcelain', {
|
|
452
|
+
cwd: root,
|
|
453
|
+
encoding: 'utf8',
|
|
454
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
455
|
+
}).trim();
|
|
456
|
+
return status === '';
|
|
457
|
+
} catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function getWorkingTreeChanges(root) {
|
|
463
|
+
try {
|
|
464
|
+
// Staged + unstaged tracked changes
|
|
465
|
+
const tracked = execSync('git diff --name-only HEAD', {
|
|
466
|
+
cwd: root,
|
|
467
|
+
encoding: 'utf8',
|
|
468
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
469
|
+
}).trim();
|
|
470
|
+
|
|
471
|
+
// Staged changes (for files added with git add)
|
|
472
|
+
const staged = execSync('git diff --name-only --cached', {
|
|
473
|
+
cwd: root,
|
|
474
|
+
encoding: 'utf8',
|
|
475
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
476
|
+
}).trim();
|
|
477
|
+
|
|
478
|
+
// Untracked files
|
|
479
|
+
const untracked = execSync('git ls-files --others --exclude-standard', {
|
|
480
|
+
cwd: root,
|
|
481
|
+
encoding: 'utf8',
|
|
482
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
483
|
+
}).trim();
|
|
484
|
+
|
|
485
|
+
const all = new Set();
|
|
486
|
+
for (const line of [tracked, staged, untracked]) {
|
|
487
|
+
for (const f of line.split('\n').filter(Boolean)) {
|
|
488
|
+
all.add(f);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return [...all];
|
|
492
|
+
} catch {
|
|
493
|
+
return [];
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function captureDirtyWorkspaceSnapshot(root) {
|
|
498
|
+
const snapshot = {};
|
|
499
|
+
for (const filePath of getWorkingTreeChanges(root).filter((filePath) => !isOperationalPath(filePath))) {
|
|
500
|
+
snapshot[filePath] = getWorkspaceFileMarker(root, filePath);
|
|
501
|
+
}
|
|
502
|
+
return snapshot;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function filterBaselineDirtyFiles(root, changedFiles, baseline) {
|
|
506
|
+
const snapshot = baseline?.dirty_snapshot;
|
|
507
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
|
508
|
+
return changedFiles;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return changedFiles.filter((filePath) => {
|
|
512
|
+
if (!(filePath in snapshot)) {
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
return snapshot[filePath] !== getWorkspaceFileMarker(root, filePath);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function getWorkspaceFileMarker(root, filePath) {
|
|
520
|
+
const absPath = join(root, filePath);
|
|
521
|
+
if (!existsSync(absPath)) {
|
|
522
|
+
return 'deleted';
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
const content = readFileSync(absPath);
|
|
527
|
+
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
528
|
+
} catch {
|
|
529
|
+
return 'unreadable';
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function getUntrackedFiles(root) {
|
|
534
|
+
try {
|
|
535
|
+
const result = execSync('git ls-files --others --exclude-standard', {
|
|
536
|
+
cwd: root,
|
|
537
|
+
encoding: 'utf8',
|
|
538
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
539
|
+
}).trim();
|
|
540
|
+
return result ? result.split('\n').filter(Boolean) : [];
|
|
541
|
+
} catch {
|
|
542
|
+
return [];
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function getCommittedChanges(root, baseRef) {
|
|
547
|
+
try {
|
|
548
|
+
const result = execSync(`git diff --name-only ${baseRef}`, {
|
|
549
|
+
cwd: root,
|
|
550
|
+
encoding: 'utf8',
|
|
551
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
552
|
+
}).trim();
|
|
553
|
+
return result ? result.split('\n').filter(Boolean) : [];
|
|
554
|
+
} catch {
|
|
555
|
+
return [];
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function getWorkingTreeDiffSummary(root) {
|
|
560
|
+
try {
|
|
561
|
+
return execSync('git diff --stat HEAD', {
|
|
562
|
+
cwd: root,
|
|
563
|
+
encoding: 'utf8',
|
|
564
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
565
|
+
}).trim() || null;
|
|
566
|
+
} catch {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function getDiffSummary(root, baseRef) {
|
|
572
|
+
try {
|
|
573
|
+
return execSync(`git diff --stat ${baseRef}`, {
|
|
574
|
+
cwd: root,
|
|
575
|
+
encoding: 'utf8',
|
|
576
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
577
|
+
}).trim() || null;
|
|
578
|
+
} catch {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function buildObservedDiffSummary(baseSummary, untrackedFiles) {
|
|
584
|
+
const untrackedSummary = untrackedFiles.length > 0
|
|
585
|
+
? ['Untracked files:', ...untrackedFiles.map((filePath) => ` - ${filePath}`)].join('\n')
|
|
586
|
+
: null;
|
|
587
|
+
|
|
588
|
+
if (baseSummary && untrackedSummary) {
|
|
589
|
+
return `${baseSummary}\n${untrackedSummary}`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return baseSummary || untrackedSummary;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function isAllowedReviewPath(filePath) {
|
|
596
|
+
return filePath.startsWith('.planning/') || filePath.startsWith('.agentxchain/reviews/') || isOperationalPath(filePath);
|
|
597
|
+
}
|
package/src/lib/repo.js
CHANGED
|
@@ -1,36 +1,5 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
2
|
|
|
3
|
-
export async function getRepoUrl(root) {
|
|
4
|
-
try {
|
|
5
|
-
const raw = execSync('git remote get-url origin', {
|
|
6
|
-
cwd: root,
|
|
7
|
-
encoding: 'utf8',
|
|
8
|
-
stdio: ['ignore', 'pipe', 'ignore']
|
|
9
|
-
}).trim();
|
|
10
|
-
|
|
11
|
-
// Convert SSH to HTTPS if needed
|
|
12
|
-
// git@github.com:user/repo.git -> https://github.com/user/repo
|
|
13
|
-
if (raw.startsWith('git@github.com:')) {
|
|
14
|
-
const path = raw.replace('git@github.com:', '').replace(/\.git$/, '');
|
|
15
|
-
return `https://github.com/${path}`;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Strip embedded credentials/tokens from HTTPS URLs.
|
|
19
|
-
// https://x-access-token:TOKEN@github.com/org/repo.git -> https://github.com/org/repo
|
|
20
|
-
// https://user:pass@github.com/org/repo.git -> https://github.com/org/repo
|
|
21
|
-
const credentialStripped = raw.replace(/^https?:\/\/[^/@]+@github\.com\//, 'https://github.com/');
|
|
22
|
-
|
|
23
|
-
// Already HTTPS — strip .git suffix
|
|
24
|
-
if (credentialStripped.includes('github.com')) {
|
|
25
|
-
return credentialStripped.replace(/\.git$/, '');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return credentialStripped;
|
|
29
|
-
} catch {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
3
|
export function getCurrentBranch(root) {
|
|
35
4
|
try {
|
|
36
5
|
const current = execSync('git rev-parse --abbrev-ref HEAD', {
|