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.
Files changed (74) hide show
  1. package/README.md +126 -142
  2. package/bin/agentxchain.js +186 -5
  3. package/dashboard/app.js +305 -0
  4. package/dashboard/components/blocked.js +145 -0
  5. package/dashboard/components/cross-repo.js +126 -0
  6. package/dashboard/components/gate.js +311 -0
  7. package/dashboard/components/hooks.js +177 -0
  8. package/dashboard/components/initiative.js +147 -0
  9. package/dashboard/components/ledger.js +165 -0
  10. package/dashboard/components/timeline.js +222 -0
  11. package/dashboard/index.html +352 -0
  12. package/package.json +14 -6
  13. package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
  14. package/scripts/publish-from-tag.sh +88 -0
  15. package/scripts/release-postflight.sh +231 -0
  16. package/scripts/release-preflight.sh +167 -0
  17. package/src/commands/accept-turn.js +160 -0
  18. package/src/commands/approve-completion.js +80 -0
  19. package/src/commands/approve-transition.js +85 -0
  20. package/src/commands/dashboard.js +70 -0
  21. package/src/commands/init.js +516 -0
  22. package/src/commands/migrate.js +348 -0
  23. package/src/commands/multi.js +549 -0
  24. package/src/commands/plugin.js +157 -0
  25. package/src/commands/reject-turn.js +204 -0
  26. package/src/commands/resume.js +389 -0
  27. package/src/commands/status.js +196 -3
  28. package/src/commands/step.js +947 -0
  29. package/src/commands/template-list.js +33 -0
  30. package/src/commands/template-set.js +279 -0
  31. package/src/commands/validate.js +20 -11
  32. package/src/commands/verify.js +71 -0
  33. package/src/lib/adapters/api-proxy-adapter.js +1076 -0
  34. package/src/lib/adapters/local-cli-adapter.js +337 -0
  35. package/src/lib/adapters/manual-adapter.js +169 -0
  36. package/src/lib/blocked-state.js +94 -0
  37. package/src/lib/config.js +97 -1
  38. package/src/lib/context-compressor.js +121 -0
  39. package/src/lib/context-section-parser.js +220 -0
  40. package/src/lib/coordinator-acceptance.js +428 -0
  41. package/src/lib/coordinator-config.js +461 -0
  42. package/src/lib/coordinator-dispatch.js +276 -0
  43. package/src/lib/coordinator-gates.js +487 -0
  44. package/src/lib/coordinator-hooks.js +239 -0
  45. package/src/lib/coordinator-recovery.js +523 -0
  46. package/src/lib/coordinator-state.js +365 -0
  47. package/src/lib/cross-repo-context.js +247 -0
  48. package/src/lib/dashboard/bridge-server.js +284 -0
  49. package/src/lib/dashboard/file-watcher.js +93 -0
  50. package/src/lib/dashboard/state-reader.js +96 -0
  51. package/src/lib/dispatch-bundle.js +568 -0
  52. package/src/lib/dispatch-manifest.js +252 -0
  53. package/src/lib/gate-evaluator.js +285 -0
  54. package/src/lib/governed-state.js +2139 -0
  55. package/src/lib/governed-templates.js +145 -0
  56. package/src/lib/hook-runner.js +788 -0
  57. package/src/lib/normalized-config.js +539 -0
  58. package/src/lib/plugin-config-schema.js +192 -0
  59. package/src/lib/plugins.js +692 -0
  60. package/src/lib/protocol-conformance.js +291 -0
  61. package/src/lib/reference-conformance-adapter.js +717 -0
  62. package/src/lib/repo-observer.js +597 -0
  63. package/src/lib/repo.js +0 -31
  64. package/src/lib/schema.js +121 -0
  65. package/src/lib/schemas/turn-result.schema.json +205 -0
  66. package/src/lib/token-budget.js +206 -0
  67. package/src/lib/token-counter.js +27 -0
  68. package/src/lib/turn-paths.js +67 -0
  69. package/src/lib/turn-result-validator.js +496 -0
  70. package/src/lib/validation.js +137 -0
  71. package/src/templates/governed/api-service.json +31 -0
  72. package/src/templates/governed/cli-tool.json +30 -0
  73. package/src/templates/governed/generic.json +10 -0
  74. 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', {