jules-orchestrator-kit 0.38.0 → 0.38.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/.agent/rules/jules-protocol.md +3 -0
- package/JULES_RULES_TEMPLATE.md +3 -1
- package/README.md +163 -307
- package/bin/agentctl.mjs +177 -7
- package/bin/init.js +8 -8
- package/index.mjs +14 -2
- package/package.json +1 -1
- package/scripts/command-resolver.mjs +1 -2
- package/scripts/doc-sync-check.mjs +36 -1
- package/scripts/jules-create.mjs +1 -1
- package/scripts/jules-dispatch.mjs +1 -2
- package/scripts/jules-nightly.mjs +2 -2
- package/scripts/jules-patch.mjs +2 -2
- package/scripts/jules-queue-runner.mjs +1 -1
- package/scripts/jules-scan-todos.mjs +1 -1
- package/scripts/jules-self-audit.mjs +1 -2
- package/scripts/jules-status.mjs +1 -1
- package/scripts/release.mjs +55 -0
- package/scripts/run-tests.mjs +15 -1
- package/scripts/utils.mjs +14 -9
- package/src/asset-integrity.mjs +76 -0
- package/src/asset_integrity.mjs +1 -76
- package/src/config.mjs +42 -23
- package/src/dag-engine.mjs +44 -6
- package/src/engine.mjs +18 -11
- package/src/execution-envelope.mjs +111 -0
- package/src/execution_envelope.mjs +1 -111
- package/src/git.mjs +6 -3
- package/src/merge-blocks.mjs +178 -1
- package/src/ops/command-registry.mjs +25 -0
- package/src/ops/handover.mjs +411 -0
- package/src/ops/ide-scaffold.mjs +3 -3
- package/src/ops/next-step.mjs +6 -2
- package/src/prompt-guard.mjs +34 -3
- package/src/provider.mjs +191 -86
- package/src/role-resolver.mjs +31 -0
- package/src/rules-budget.mjs +179 -0
- package/src/rules_budget.mjs +1 -179
- package/src/security.mjs +98 -56
- package/src/state.mjs +11 -6
- package/src/task-optimizer.mjs +10 -2
- package/src/tui.mjs +1 -1
- package/src/webhook.mjs +2 -2
- package/src/wizard-task.mjs +4 -30
package/src/merge-blocks.mjs
CHANGED
|
@@ -279,7 +279,27 @@ export function mergeBlocks3Way(baseText = "", oursText = "", theirsText = "", l
|
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
|
|
282
|
+
let mergedText = outputBlocks.join("\n\n");
|
|
283
|
+
|
|
284
|
+
if (conflicts > 0) {
|
|
285
|
+
if (lang === "json") {
|
|
286
|
+
try {
|
|
287
|
+
const autoResolved = resolveJsonConflict(mergedText);
|
|
288
|
+
if (autoResolved && !autoResolved.includes("<<<<<<<")) {
|
|
289
|
+
mergedText = autoResolved;
|
|
290
|
+
conflicts = 0;
|
|
291
|
+
}
|
|
292
|
+
} catch (_) {}
|
|
293
|
+
} else if (lang === "md" || lang === "markdown") {
|
|
294
|
+
try {
|
|
295
|
+
const autoResolved = resolveMarkdownConflict(mergedText);
|
|
296
|
+
if (autoResolved && !autoResolved.includes("<<<<<<<")) {
|
|
297
|
+
mergedText = autoResolved;
|
|
298
|
+
conflicts = 0;
|
|
299
|
+
}
|
|
300
|
+
} catch (_) {}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
283
303
|
|
|
284
304
|
return {
|
|
285
305
|
mergedText,
|
|
@@ -290,3 +310,160 @@ export function mergeBlocks3Way(baseText = "", oursText = "", theirsText = "", l
|
|
|
290
310
|
},
|
|
291
311
|
};
|
|
292
312
|
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Deep-merges two parsed JSON values.
|
|
316
|
+
* - Objects: merged recursively; HEAD (ours) wins on scalar conflicts.
|
|
317
|
+
* - Arrays: concatenated with primitive deduplication and deep object equality check.
|
|
318
|
+
* - Primitives: HEAD (ours) wins unless null/undefined.
|
|
319
|
+
*/
|
|
320
|
+
export function deepMergeJson(head, dev) {
|
|
321
|
+
if (head === null || head === undefined) return dev;
|
|
322
|
+
if (dev === null || dev === undefined) return head;
|
|
323
|
+
|
|
324
|
+
if (Array.isArray(head) && Array.isArray(dev)) {
|
|
325
|
+
const combined = [...head];
|
|
326
|
+
for (const item of dev) {
|
|
327
|
+
const isPrimitive = typeof item !== "object" || item === null;
|
|
328
|
+
if (isPrimitive) {
|
|
329
|
+
if (!combined.includes(item)) combined.push(item);
|
|
330
|
+
} else {
|
|
331
|
+
const itemStr = JSON.stringify(item);
|
|
332
|
+
const exists = combined.some((c) => JSON.stringify(c) === itemStr);
|
|
333
|
+
if (!exists) combined.push(item);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return combined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (typeof head === "object" && typeof dev === "object" && !Array.isArray(head) && !Array.isArray(dev)) {
|
|
340
|
+
const result = { ...dev };
|
|
341
|
+
for (const key of Object.keys(head)) {
|
|
342
|
+
if (key in result) {
|
|
343
|
+
result[key] = deepMergeJson(head[key], result[key]);
|
|
344
|
+
} else {
|
|
345
|
+
result[key] = head[key];
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Scalar conflict: HEAD (ours) wins
|
|
352
|
+
return head;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Automatically resolves merge conflicts in JSON files.
|
|
357
|
+
* Extracts <<<<<<< / ======= / >>>>>>> markers, parses both branches, deep-merges them,
|
|
358
|
+
* and returns the formatted JSON string.
|
|
359
|
+
*
|
|
360
|
+
* @param {string} content - Raw conflict-marked file content
|
|
361
|
+
* @returns {string} Clean, resolved JSON string (or original content if unresolvable)
|
|
362
|
+
*/
|
|
363
|
+
export function resolveJsonConflict(content) {
|
|
364
|
+
if (typeof content !== "string") return content;
|
|
365
|
+
if (!content.includes("<<<<<<<") || !content.includes("=======") || !content.includes(">>>>>>>")) {
|
|
366
|
+
return content;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Check if the whole file or a segment is marked as conflict
|
|
370
|
+
const fullConflictRegex = /^<{7}.*?\r?\n([\s\S]*?)\r?\n={7}\r?\n([\s\S]*?)\r?\n>{7}.*?$/m;
|
|
371
|
+
const match = content.match(fullConflictRegex);
|
|
372
|
+
|
|
373
|
+
if (match) {
|
|
374
|
+
const headRaw = match[1].trim();
|
|
375
|
+
const devRaw = match[2].trim();
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const headJson = JSON.parse(headRaw);
|
|
379
|
+
const devJson = JSON.parse(devRaw);
|
|
380
|
+
const merged = deepMergeJson(headJson, devJson);
|
|
381
|
+
return JSON.stringify(merged, null, 2) + "\n";
|
|
382
|
+
} catch (_) {
|
|
383
|
+
// If individual branches are partial JSON snippets, fall through to block resolution
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Multi-block / partial JSON conflict resolution
|
|
388
|
+
const conflictBlockRegex = /<{7}[^\n]*\r?\n([\s\S]*?)\r?\n={7}\r?\n([\s\S]*?)\r?\n>{7}[^\n]*/g;
|
|
389
|
+
const resolved = content.replace(conflictBlockRegex, (_, headBlock, devBlock) => {
|
|
390
|
+
try {
|
|
391
|
+
const h = JSON.parse(`{${headBlock}}`);
|
|
392
|
+
const d = JSON.parse(`{${devBlock}}`);
|
|
393
|
+
const m = deepMergeJson(h, d);
|
|
394
|
+
const inner = JSON.stringify(m, null, 2).replace(/^\{\s*\n?/, "").replace(/\n?\s*\}$/, "");
|
|
395
|
+
return inner;
|
|
396
|
+
} catch (_) {
|
|
397
|
+
// Fallback: take head (ours)
|
|
398
|
+
return headBlock.trim();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
return resolved;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Automatically resolves merge conflicts in Markdown files (changelogs, notes, docs).
|
|
407
|
+
* Removes conflict markers, deduplicates repeated items, and concatenates updates.
|
|
408
|
+
*
|
|
409
|
+
* @param {string} content - Raw conflict-marked markdown content
|
|
410
|
+
* @returns {string} Resolved Markdown text
|
|
411
|
+
*/
|
|
412
|
+
export function resolveMarkdownConflict(content) {
|
|
413
|
+
if (typeof content !== "string") return content;
|
|
414
|
+
if (!content.includes("<<<<<<<") || !content.includes("=======") || !content.includes(">>>>>>>")) {
|
|
415
|
+
return content;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const lines = content.split(/\r?\n/);
|
|
419
|
+
const result = [];
|
|
420
|
+
let inConflict = false;
|
|
421
|
+
let headBuffer = [];
|
|
422
|
+
let devBuffer = [];
|
|
423
|
+
let section = null; // 'head' | 'dev'
|
|
424
|
+
|
|
425
|
+
for (let i = 0; i < lines.length; i++) {
|
|
426
|
+
const line = lines[i];
|
|
427
|
+
|
|
428
|
+
if (line.startsWith("<<<<<<<")) {
|
|
429
|
+
inConflict = true;
|
|
430
|
+
section = "head";
|
|
431
|
+
headBuffer = [];
|
|
432
|
+
devBuffer = [];
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (inConflict && line.startsWith("=======")) {
|
|
437
|
+
section = "dev";
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (inConflict && line.startsWith(">>>>>>>")) {
|
|
442
|
+
inConflict = false;
|
|
443
|
+
section = null;
|
|
444
|
+
|
|
445
|
+
// Concatenate both sides while deduplicating identical lines
|
|
446
|
+
const seen = new Set();
|
|
447
|
+
for (const hLine of headBuffer) {
|
|
448
|
+
result.push(hLine);
|
|
449
|
+
if (hLine.trim()) seen.add(hLine.trim());
|
|
450
|
+
}
|
|
451
|
+
for (const dLine of devBuffer) {
|
|
452
|
+
if (!seen.has(dLine.trim())) {
|
|
453
|
+
result.push(dLine);
|
|
454
|
+
if (dLine.trim()) seen.add(dLine.trim());
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (inConflict) {
|
|
461
|
+
if (section === "head") headBuffer.push(line);
|
|
462
|
+
else if (section === "dev") devBuffer.push(line);
|
|
463
|
+
} else {
|
|
464
|
+
result.push(line);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return result.join("\n");
|
|
469
|
+
}
|
|
@@ -276,6 +276,31 @@ export const COMMAND_REGISTRY = [
|
|
|
276
276
|
{ name: "json", type: "boolean", description: "Output JSON structured response" },
|
|
277
277
|
],
|
|
278
278
|
},
|
|
279
|
+
{
|
|
280
|
+
id: "handover",
|
|
281
|
+
path: ["handover"],
|
|
282
|
+
title: "handover",
|
|
283
|
+
description: "Inspect or generate Baton Pass session handover envelopes",
|
|
284
|
+
category: "Operate",
|
|
285
|
+
mutates: true,
|
|
286
|
+
risk: "low",
|
|
287
|
+
interactive: "never",
|
|
288
|
+
requiresRepository: true,
|
|
289
|
+
shortcuts: ["ho"],
|
|
290
|
+
examples: [
|
|
291
|
+
"agentctl handover list",
|
|
292
|
+
"agentctl handover show sess-123",
|
|
293
|
+
'agentctl handover create sess-123 --intent "Refactor auth" --status rolled-back',
|
|
294
|
+
],
|
|
295
|
+
flags: [
|
|
296
|
+
{ name: "intent", type: "string", description: "Task intent or goal description" },
|
|
297
|
+
{ name: "status", type: "string", description: "Handover status (aborted, rolled-back, escalated, failed)" },
|
|
298
|
+
{ name: "landmines", type: "string", description: "Obstacles or error summary" },
|
|
299
|
+
{ name: "next-steps", type: "string", description: "Actionable next steps for successor agent" },
|
|
300
|
+
{ name: "json", type: "boolean", description: "Output JSON structured response" },
|
|
301
|
+
{ name: "limit", type: "string", description: "Maximum handovers to list" },
|
|
302
|
+
],
|
|
303
|
+
},
|
|
279
304
|
];
|
|
280
305
|
|
|
281
306
|
/**
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFileSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
statSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
openSync,
|
|
10
|
+
writeSync,
|
|
11
|
+
fsyncSync,
|
|
12
|
+
closeSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { join, resolve, relative, isAbsolute, basename } from "node:path";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { redactSecrets } from "../security.mjs";
|
|
17
|
+
import { resolveRoot } from "../config.mjs";
|
|
18
|
+
|
|
19
|
+
export class HandoverError extends Error {
|
|
20
|
+
constructor(message, opts = {}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "HandoverError";
|
|
23
|
+
this.code = opts.code || 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {"aborted" | "escalated" | "rolled-back" | "quarantined" | "completed" | "failed"} HandoverStatus
|
|
29
|
+
*
|
|
30
|
+
* @typedef {Object} HandoverPayload
|
|
31
|
+
* @property {string} sessionId
|
|
32
|
+
* @property {HandoverStatus} [status]
|
|
33
|
+
* @property {string} [intent]
|
|
34
|
+
* @property {string[] | string} [completed]
|
|
35
|
+
* @property {string[] | string} [assumptions]
|
|
36
|
+
* @property {string[] | string} [landmines]
|
|
37
|
+
* @property {string[] | string} [nextSteps]
|
|
38
|
+
* @property {string[]} [referencedPaths]
|
|
39
|
+
* @property {string} [branch]
|
|
40
|
+
* @property {string} [headSha]
|
|
41
|
+
* @property {string} [diffSummary]
|
|
42
|
+
* @property {Record<string, any>} [metadata]
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Ensures handover storage directory exists.
|
|
47
|
+
* @param {string} root
|
|
48
|
+
* @returns {string} Absolute path to .agent/handovers directory
|
|
49
|
+
*/
|
|
50
|
+
export function getHandoverDir(root = resolveRoot()) {
|
|
51
|
+
const dir = join(root, ".agent", "handovers");
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
} catch (_) {}
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function assertSessionId(sessionId) {
|
|
59
|
+
if (typeof sessionId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(sessionId)) {
|
|
60
|
+
throw new HandoverError(`Invalid handover session id: "${sessionId}"`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toItemArray(value) {
|
|
65
|
+
if (!value) return [];
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
return value.map((item) => String(item).trim()).filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
return String(value)
|
|
70
|
+
.split(/\r?\n/)
|
|
71
|
+
.map((line) => line.replace(/^[-*•]\s+/, "").trim())
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sanitizeText(value) {
|
|
76
|
+
if (!value) return "";
|
|
77
|
+
return redactSecrets(String(value).trim());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeFileAtomically(filePath, content) {
|
|
81
|
+
const tmpPath = `${filePath}.${randomUUID()}.tmp`;
|
|
82
|
+
let fd;
|
|
83
|
+
try {
|
|
84
|
+
fd = openSync(tmpPath, "wx", 0o600);
|
|
85
|
+
writeSync(fd, content, "utf-8");
|
|
86
|
+
fsyncSync(fd);
|
|
87
|
+
closeSync(fd);
|
|
88
|
+
fd = undefined;
|
|
89
|
+
renameSync(tmpPath, filePath);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (fd !== undefined) {
|
|
92
|
+
try { closeSync(fd); } catch (_) {}
|
|
93
|
+
}
|
|
94
|
+
try { unlinkSync(tmpPath); } catch (_) {}
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Creates and persists a Baton Pass handover manifest in .agent/handovers/YYYY-MM-DD-[sessionId].md.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} root - Project root directory
|
|
103
|
+
* @param {HandoverPayload} data - Handover details
|
|
104
|
+
* @param {Object} [options]
|
|
105
|
+
* @param {number} [options.maxRetention=20] - Max handovers to retain
|
|
106
|
+
* @returns {{ sessionId: string, filePath: string, createdAt: string, status: string, markdown: string }}
|
|
107
|
+
*/
|
|
108
|
+
export function createHandover(root = resolveRoot(), data = {}, options = {}) {
|
|
109
|
+
const handoverDir = getHandoverDir(root);
|
|
110
|
+
const sessionId = data.sessionId || `session-${Date.now()}`;
|
|
111
|
+
assertSessionId(sessionId);
|
|
112
|
+
|
|
113
|
+
const createdAt = data.createdAt || new Date().toISOString();
|
|
114
|
+
const datePrefix = createdAt.substring(0, 10);
|
|
115
|
+
const status = data.status || "aborted";
|
|
116
|
+
const branch = sanitizeText(data.branch || "");
|
|
117
|
+
const headSha = sanitizeText(data.headSha || "");
|
|
118
|
+
|
|
119
|
+
const intent = sanitizeText(data.intent || "");
|
|
120
|
+
const completed = toItemArray(data.completed).map(sanitizeText);
|
|
121
|
+
const assumptions = toItemArray(data.assumptions).map(sanitizeText);
|
|
122
|
+
const landmines = toItemArray(data.landmines).map(sanitizeText);
|
|
123
|
+
const nextSteps = toItemArray(data.nextSteps).map(sanitizeText);
|
|
124
|
+
const referencedPaths = (Array.isArray(data.referencedPaths) ? data.referencedPaths : [])
|
|
125
|
+
.map((p) => sanitizeText(p))
|
|
126
|
+
.filter(Boolean);
|
|
127
|
+
const diffSummary = sanitizeText(data.diffSummary || "");
|
|
128
|
+
|
|
129
|
+
const fileName = `${datePrefix}-${sessionId}.md`;
|
|
130
|
+
const filePath = join(handoverDir, fileName);
|
|
131
|
+
|
|
132
|
+
// Build YAML frontmatter + structured markdown
|
|
133
|
+
const frontmatterLines = [
|
|
134
|
+
"---",
|
|
135
|
+
"schema: agentctl/handover-v1",
|
|
136
|
+
`session_id: "${sessionId}"`,
|
|
137
|
+
`created_at: "${createdAt}"`,
|
|
138
|
+
`status: "${status}"`,
|
|
139
|
+
];
|
|
140
|
+
if (branch) frontmatterLines.push(`branch: "${branch}"`);
|
|
141
|
+
if (headSha) frontmatterLines.push(`head_sha: "${headSha}"`);
|
|
142
|
+
if (referencedPaths.length > 0) {
|
|
143
|
+
frontmatterLines.push("referenced_paths:");
|
|
144
|
+
for (const p of referencedPaths) {
|
|
145
|
+
frontmatterLines.push(` - "${p}"`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
frontmatterLines.push("---", "");
|
|
149
|
+
|
|
150
|
+
const bodySections = [
|
|
151
|
+
`# Agent Handover: ${sessionId}`,
|
|
152
|
+
"",
|
|
153
|
+
`**Status:** \`${status}\` `,
|
|
154
|
+
`**Recorded At:** \`${createdAt}\` `,
|
|
155
|
+
branch ? `**Branch:** \`${branch}\` ` : "",
|
|
156
|
+
headSha ? `**Commit HEAD:** \`${headSha}\` ` : "",
|
|
157
|
+
"",
|
|
158
|
+
"## Intent",
|
|
159
|
+
intent ? intent : "*(No specific intent recorded)*",
|
|
160
|
+
"",
|
|
161
|
+
"## Completed Work",
|
|
162
|
+
completed.length > 0 ? completed.map((c) => `- ${c}`).join("\n") : "*(No completed steps recorded)*",
|
|
163
|
+
"",
|
|
164
|
+
"## Validated Assumptions",
|
|
165
|
+
assumptions.length > 0 ? assumptions.map((a) => `- ${a}`).join("\n") : "*(No explicit assumptions noted)*",
|
|
166
|
+
"",
|
|
167
|
+
"## Landmines & Failures",
|
|
168
|
+
landmines.length > 0 ? landmines.map((l) => `- ${l}`).join("\n") : "*(No blockers or landmines reported)*",
|
|
169
|
+
"",
|
|
170
|
+
"## Recommended Next Steps",
|
|
171
|
+
nextSteps.length > 0 ? nextSteps.map((s) => `- ${s}`).join("\n") : "*(No immediate next steps specified)*",
|
|
172
|
+
].filter(Boolean);
|
|
173
|
+
|
|
174
|
+
if (referencedPaths.length > 0) {
|
|
175
|
+
bodySections.push(
|
|
176
|
+
"",
|
|
177
|
+
"## Referenced Paths",
|
|
178
|
+
...referencedPaths.map((p) => `- \`${p}\``)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (diffSummary) {
|
|
183
|
+
bodySections.push(
|
|
184
|
+
"",
|
|
185
|
+
"## Diff Summary",
|
|
186
|
+
"```text",
|
|
187
|
+
diffSummary,
|
|
188
|
+
"```"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
bodySections.push("");
|
|
193
|
+
const fullContent = frontmatterLines.join("\n") + bodySections.join("\n");
|
|
194
|
+
|
|
195
|
+
writeFileAtomically(filePath, fullContent);
|
|
196
|
+
|
|
197
|
+
const retention = typeof options.maxRetention === "number" ? options.maxRetention : 20;
|
|
198
|
+
if (retention > 0) {
|
|
199
|
+
pruneHandovers(root, retention);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
sessionId,
|
|
204
|
+
filePath,
|
|
205
|
+
createdAt,
|
|
206
|
+
status,
|
|
207
|
+
markdown: fullContent,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Loads and parses a handover file by sessionId or filepath.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} root
|
|
215
|
+
* @param {string} sessionIdOrPath
|
|
216
|
+
* @returns {Object} Structured handover object
|
|
217
|
+
*/
|
|
218
|
+
export function loadHandover(root = resolveRoot(), sessionIdOrPath = "") {
|
|
219
|
+
const handoverDir = resolve(root, ".agent", "handovers");
|
|
220
|
+
let targetPath;
|
|
221
|
+
|
|
222
|
+
if (!sessionIdOrPath || typeof sessionIdOrPath !== "string") {
|
|
223
|
+
throw new HandoverError("Handover session id or path is required");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (sessionIdOrPath.endsWith(".md")) {
|
|
227
|
+
targetPath = isAbsolute(sessionIdOrPath) ? resolve(sessionIdOrPath) : resolve(root, sessionIdOrPath);
|
|
228
|
+
const rel = relative(handoverDir, targetPath);
|
|
229
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
230
|
+
throw new HandoverError(`Handover path escapes handovers directory: ${sessionIdOrPath}`);
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
assertSessionId(sessionIdOrPath);
|
|
234
|
+
// Find matching file in directory with date prefix
|
|
235
|
+
const files = existsSync(handoverDir) ? readdirSync(handoverDir).filter((f) => f.endsWith(".md")) : [];
|
|
236
|
+
const match = files.find((f) => f === `${sessionIdOrPath}.md` || f.endsWith(`-${sessionIdOrPath}.md`));
|
|
237
|
+
if (!match) {
|
|
238
|
+
throw new HandoverError(`Handover file not found for session "${sessionIdOrPath}"`);
|
|
239
|
+
}
|
|
240
|
+
targetPath = join(handoverDir, match);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!existsSync(targetPath)) {
|
|
244
|
+
throw new HandoverError(`Handover file not found: ${targetPath}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const raw = readFileSync(targetPath, "utf-8");
|
|
248
|
+
|
|
249
|
+
// Parse YAML Frontmatter
|
|
250
|
+
let frontmatter = {};
|
|
251
|
+
let markdownBody = raw;
|
|
252
|
+
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
253
|
+
if (fmMatch) {
|
|
254
|
+
const fmLines = fmMatch[1].split(/\r?\n/);
|
|
255
|
+
markdownBody = fmMatch[2];
|
|
256
|
+
for (const line of fmLines) {
|
|
257
|
+
const kv = line.match(/^([a-z_]+):\s*(?:"(.*)"|(.*))$/);
|
|
258
|
+
if (kv) {
|
|
259
|
+
frontmatter[kv[1]] = (kv[2] !== undefined ? kv[2] : kv[3] || "").trim();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Parse markdown sections
|
|
265
|
+
function extractSection(title) {
|
|
266
|
+
const regex = new RegExp(`## ${title}\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |$)`, "i");
|
|
267
|
+
const m = markdownBody.match(regex);
|
|
268
|
+
if (!m) return "";
|
|
269
|
+
return m[1].trim();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function extractListSection(title) {
|
|
273
|
+
const section = extractSection(title);
|
|
274
|
+
if (!section || section.startsWith("*(")) return [];
|
|
275
|
+
return section
|
|
276
|
+
.split(/\r?\n/)
|
|
277
|
+
.map((line) => line.replace(/^[-*•]\s+/, "").replace(/^`|`$/g, "").trim())
|
|
278
|
+
.filter(Boolean);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const intentRaw = extractSection("Intent");
|
|
282
|
+
const intent = intentRaw.startsWith("*(") ? "" : intentRaw;
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
schema: frontmatter.schema || "agentctl/handover-v1",
|
|
286
|
+
sessionId: frontmatter.session_id || basename(targetPath, ".md"),
|
|
287
|
+
createdAt: frontmatter.created_at || new Date(statSync(targetPath).mtimeMs).toISOString(),
|
|
288
|
+
status: frontmatter.status || "aborted",
|
|
289
|
+
branch: frontmatter.branch || "",
|
|
290
|
+
headSha: frontmatter.head_sha || "",
|
|
291
|
+
referencedPaths: extractListSection("Referenced Paths"),
|
|
292
|
+
intent,
|
|
293
|
+
completed: extractListSection("Completed Work"),
|
|
294
|
+
assumptions: extractListSection("Validated Assumptions"),
|
|
295
|
+
landmines: extractListSection("Landmines & Failures"),
|
|
296
|
+
nextSteps: extractListSection("Recommended Next Steps"),
|
|
297
|
+
rawMarkdown: raw,
|
|
298
|
+
filePath: targetPath,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Lists all handovers in .agent/handovers/ ordered by creation timestamp descending.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} root
|
|
306
|
+
* @returns {Array<{ sessionId: string, status: string, createdAt: string, filePath: string, intent: string }>}
|
|
307
|
+
*/
|
|
308
|
+
export function listHandovers(root = resolveRoot()) {
|
|
309
|
+
const handoverDir = getHandoverDir(root);
|
|
310
|
+
if (!existsSync(handoverDir)) return [];
|
|
311
|
+
|
|
312
|
+
const files = readdirSync(handoverDir).filter((f) => f.endsWith(".md"));
|
|
313
|
+
const items = [];
|
|
314
|
+
|
|
315
|
+
for (const file of files) {
|
|
316
|
+
const filePath = join(handoverDir, file);
|
|
317
|
+
try {
|
|
318
|
+
const parsed = loadHandover(root, filePath);
|
|
319
|
+
items.push({
|
|
320
|
+
sessionId: parsed.sessionId,
|
|
321
|
+
status: parsed.status,
|
|
322
|
+
createdAt: parsed.createdAt,
|
|
323
|
+
filePath,
|
|
324
|
+
intent: parsed.intent,
|
|
325
|
+
});
|
|
326
|
+
} catch (_) {}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
330
|
+
return items;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Prunes handovers keeping only the most recent N files.
|
|
335
|
+
*
|
|
336
|
+
* @param {string} root
|
|
337
|
+
* @param {number} [maxRetention=20]
|
|
338
|
+
* @returns {number} Count of pruned files
|
|
339
|
+
*/
|
|
340
|
+
export function pruneHandovers(root = resolveRoot(), maxRetention = 20) {
|
|
341
|
+
const list = listHandovers(root);
|
|
342
|
+
if (list.length <= maxRetention) return 0;
|
|
343
|
+
|
|
344
|
+
const toRemove = list.slice(maxRetention);
|
|
345
|
+
let prunedCount = 0;
|
|
346
|
+
|
|
347
|
+
for (const item of toRemove) {
|
|
348
|
+
try {
|
|
349
|
+
if (existsSync(item.filePath)) {
|
|
350
|
+
unlinkSync(item.filePath);
|
|
351
|
+
prunedCount++;
|
|
352
|
+
}
|
|
353
|
+
} catch (_) {}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return prunedCount;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Formats a handover object into a compact, token-efficient prompt context block.
|
|
361
|
+
*
|
|
362
|
+
* @param {Object} handover - Parsed handover object
|
|
363
|
+
* @returns {string} Formatted [SESSION_HANDOVER_CONTEXT] string
|
|
364
|
+
*/
|
|
365
|
+
export function formatHandoverPromptContext(handover) {
|
|
366
|
+
if (!handover || typeof handover !== "object") return "";
|
|
367
|
+
|
|
368
|
+
const lines = [
|
|
369
|
+
"[SESSION_HANDOVER_CONTEXT]",
|
|
370
|
+
`Previous Session: ${handover.sessionId || "unknown"} (Status: ${handover.status || "aborted"}, Created: ${handover.createdAt || "N/A"})`,
|
|
371
|
+
];
|
|
372
|
+
|
|
373
|
+
if (handover.intent) {
|
|
374
|
+
lines.push(`Prior Intent: ${handover.intent}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (Array.isArray(handover.completed) && handover.completed.length > 0) {
|
|
378
|
+
lines.push("Completed Progress:");
|
|
379
|
+
for (const c of handover.completed) {
|
|
380
|
+
lines.push(` - ${c}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (Array.isArray(handover.assumptions) && handover.assumptions.length > 0) {
|
|
385
|
+
lines.push("Validated Assumptions:");
|
|
386
|
+
for (const a of handover.assumptions) {
|
|
387
|
+
lines.push(` - ${a}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (Array.isArray(handover.landmines) && handover.landmines.length > 0) {
|
|
392
|
+
lines.push("Obstacles & Landmines:");
|
|
393
|
+
for (const l of handover.landmines) {
|
|
394
|
+
lines.push(` - ${l}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (Array.isArray(handover.nextSteps) && handover.nextSteps.length > 0) {
|
|
399
|
+
lines.push("Recommended Next Steps:");
|
|
400
|
+
for (const s of handover.nextSteps) {
|
|
401
|
+
lines.push(` - ${s}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (Array.isArray(handover.referencedPaths) && handover.referencedPaths.length > 0) {
|
|
406
|
+
lines.push(`Referenced Files: ${handover.referencedPaths.join(", ")}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
lines.push("[/SESSION_HANDOVER_CONTEXT]");
|
|
410
|
+
return lines.join("\n");
|
|
411
|
+
}
|
package/src/ops/ide-scaffold.mjs
CHANGED
|
@@ -75,21 +75,21 @@ export function scaffoldIdeConfig(target = "all", options = {}) {
|
|
|
75
75
|
{
|
|
76
76
|
label: "Jules: Run System Doctor",
|
|
77
77
|
type: "shell",
|
|
78
|
-
command: "npx
|
|
78
|
+
command: "npx jules-orchestrator-kit doctor",
|
|
79
79
|
group: "test",
|
|
80
80
|
problemMatcher: [],
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
label: "Jules: Create Task Envelope",
|
|
84
84
|
type: "shell",
|
|
85
|
-
command: "npx
|
|
85
|
+
command: "npx jules-orchestrator-kit task create",
|
|
86
86
|
group: "build",
|
|
87
87
|
problemMatcher: [],
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
label: "Jules: 1-Click Rollback Checkpoint",
|
|
91
91
|
type: "shell",
|
|
92
|
-
command: "npx
|
|
92
|
+
command: "npx jules-orchestrator-kit rollback --latest",
|
|
93
93
|
group: "none",
|
|
94
94
|
problemMatcher: [],
|
|
95
95
|
},
|
package/src/ops/next-step.mjs
CHANGED
|
@@ -62,8 +62,12 @@ export function resolveNextStep(root, env = process.env) {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
const
|
|
66
|
-
const
|
|
65
|
+
const primaryQueueDir = join(root, ".agent", "jules-queue");
|
|
66
|
+
const fallbackQueueDir = join(root, ".agent", "queue");
|
|
67
|
+
const queueDir = existsSync(primaryQueueDir) ? primaryQueueDir : fallbackQueueDir;
|
|
68
|
+
const queued = existsSync(queueDir)
|
|
69
|
+
? readdirSync(queueDir).filter((f) => f.endsWith(".md") || f.endsWith(".json") || f.endsWith(".yml")).length
|
|
70
|
+
: 0;
|
|
67
71
|
if (queued > 0) {
|
|
68
72
|
return {
|
|
69
73
|
id: "queue",
|