@adhdev/daemon-core 0.9.82-rc.292 → 0.9.82-rc.294
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/dist/index.js +418 -55
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +420 -57
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-missions.d.ts +25 -1
- package/dist/mesh/mesh-runtime-store.d.ts +15 -0
- package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
- package/dist/providers/spec/cli-adapter.d.ts +14 -0
- package/dist/providers/types/interactive-prompt.d.ts +19 -0
- package/package.json +2 -2
- package/src/commands/router.ts +15 -3
- package/src/config/chat-history.ts +255 -10
- package/src/git/git-status.ts +46 -11
- package/src/mesh/coordinator-prompt.ts +3 -2
- package/src/mesh/mesh-events-coordinator.ts +51 -6
- package/src/mesh/mesh-missions.ts +41 -3
- package/src/mesh/mesh-reconcile-loop.ts +55 -0
- package/src/mesh/mesh-runtime-store.ts +30 -0
- package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
- package/src/providers/cli-provider-instance.ts +57 -30
- package/src/providers/extension-provider-instance.ts +5 -1
- package/src/providers/ide-provider-instance.ts +6 -1
- package/src/providers/spec/cli-adapter.ts +40 -0
- package/src/providers/types/interactive-prompt.ts +1 -1
|
@@ -37,6 +37,23 @@ export interface MeshMissionTaskAggregate {
|
|
|
37
37
|
export interface MeshMissionSummary extends MeshMissionRecord {
|
|
38
38
|
tasks: MeshMissionTaskAggregate;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Slim mission summary for the mesh_status compact (default) surface. Drops the
|
|
42
|
+
* full `goal` text — which can be hundreds of chars per mission and is repeated
|
|
43
|
+
* for every mission on every status call — keeping only a short preview plus a
|
|
44
|
+
* `goalTruncated` flag when the original was longer. The stored goal is never
|
|
45
|
+
* mutated; this is an output-only projection. Coordinators that need the full
|
|
46
|
+
* goal call mesh_status with verbose=true, or read the mission directly via
|
|
47
|
+
* mesh_mission_upsert / getMeshMission.
|
|
48
|
+
*/
|
|
49
|
+
export interface MeshMissionSlimSummary extends Omit<MeshMissionSummary, 'goal'> {
|
|
50
|
+
/** Short preview of the goal (≤ GOAL_PREVIEW_MAX chars), '' when goal empty. */
|
|
51
|
+
goalPreview: string;
|
|
52
|
+
/** True when the stored goal was longer than the preview (full text elided). */
|
|
53
|
+
goalTruncated: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** Max chars of goal text retained in the slim (compact) mission summary. */
|
|
56
|
+
export declare const GOAL_PREVIEW_MAX = 120;
|
|
40
57
|
export declare function upsertMeshMission(meshId: string, input: {
|
|
41
58
|
id?: string;
|
|
42
59
|
title: string;
|
|
@@ -56,10 +73,17 @@ export declare function getActiveMeshMissionSummaries(meshId: string): MeshMissi
|
|
|
56
73
|
* the dashboard can render a collapsible "history" section without unbounded
|
|
57
74
|
* payload growth. Returned newest-first within each group (active/paused first,
|
|
58
75
|
* then history), so the frontend can split on `status` directly.
|
|
76
|
+
*
|
|
77
|
+
* Compact mode (the default) elides each mission's full `goal` text — which is
|
|
78
|
+
* repeated verbatim on every status poll and dominates the payload when a mesh
|
|
79
|
+
* has many missions — returning only a short `goalPreview` + `goalTruncated`
|
|
80
|
+
* flag. Pass `verbose: true` to get the full `goal` text per mission. The stored
|
|
81
|
+
* goal is untouched in both modes; this is an output-only projection.
|
|
59
82
|
*/
|
|
60
83
|
export declare function getMeshStatusMissionSummaries(meshId: string, options?: {
|
|
61
84
|
historyLimit?: number;
|
|
62
|
-
|
|
85
|
+
verbose?: boolean;
|
|
86
|
+
}): MeshMissionSummary[] | MeshMissionSlimSummary[];
|
|
63
87
|
/**
|
|
64
88
|
* M3-3: render active missions as a prompt section for {{mission}}.
|
|
65
89
|
* Empty string when no active mission — the prompt stays byte-identical to
|
|
@@ -342,4 +342,19 @@ export declare class MeshRuntimeStore {
|
|
|
342
342
|
/** Remove all pending-event rows (drained included) for a mesh — mesh deletion / test cleanup. */
|
|
343
343
|
clearPendingEventsForMesh(meshId: string): number;
|
|
344
344
|
pendingEventCount(meshId: string): number;
|
|
345
|
+
/**
|
|
346
|
+
* Mark specific pending-event rows drained by id (ack). Used by the
|
|
347
|
+
* unresolved-delegate durable-forward outbox: an event is peeked (not drained)
|
|
348
|
+
* while its push to the coordinator is unconfirmed, then marked drained ONLY
|
|
349
|
+
* after the push is acked. A failed push leaves the row undrained so the next
|
|
350
|
+
* reconcile tick retries it. Returns the number of rows newly marked drained.
|
|
351
|
+
*/
|
|
352
|
+
markPendingEventsDrainedById(ids: ReadonlyArray<string>): number;
|
|
353
|
+
/**
|
|
354
|
+
* Hard-delete pending-event rows by id (including the dedup fingerprint history).
|
|
355
|
+
* Used to expire an unresolved-delegate outbox entry that has exhausted its retry
|
|
356
|
+
* budget — fully removing it frees the fingerprint so a genuinely new completion
|
|
357
|
+
* for the same task could be re-queued later. Returns the number of rows deleted.
|
|
358
|
+
*/
|
|
359
|
+
deletePendingEventsById(ids: ReadonlyArray<string>): number;
|
|
345
360
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const UNRESOLVED_FORWARD_OUTBOX_MESH_ID = "__unresolved_forward_outbox__";
|
|
2
|
+
export interface UnresolvedForwardEntry {
|
|
3
|
+
/** Row id in mesh_pending_events; pass back to ack/expire after a push attempt. */
|
|
4
|
+
id: string;
|
|
5
|
+
/** The coordinator daemon to push this event to (mesh_forward_event target). */
|
|
6
|
+
coordinatorDaemonId: string;
|
|
7
|
+
/** The flat payload to forward (already shaped for handleMeshForwardEvent). */
|
|
8
|
+
payload: Record<string, unknown>;
|
|
9
|
+
/** When the entry was first enqueued (epoch ms) — used for age-based expiry. */
|
|
10
|
+
queuedAt: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Durably enqueue an unresolved-delegate forward for a coordinator daemon. The
|
|
14
|
+
* `forwardPayload` is the flat shape handleMeshForwardEvent reads on the coordinator.
|
|
15
|
+
* Idempotent on the event fingerprint. Returns true when a new row was written (or a
|
|
16
|
+
* duplicate was harmlessly ignored), false on a hard persistence failure.
|
|
17
|
+
*/
|
|
18
|
+
export declare function enqueueUnresolvedDelegateForward(coordinatorDaemonId: string, eventName: string, forwardPayload: Record<string, unknown>): boolean;
|
|
19
|
+
/** Peek (non-destructive) every undrained outbox entry across all coordinators. */
|
|
20
|
+
export declare function peekUnresolvedDelegateForwards(): UnresolvedForwardEntry[];
|
|
21
|
+
/** Mark an outbox entry delivered (acked) after a successful push. */
|
|
22
|
+
export declare function ackUnresolvedDelegateForward(id: string): void;
|
|
23
|
+
/**
|
|
24
|
+
* Drop outbox entries that have exceeded the max retry age. Returns the count
|
|
25
|
+
* expired so the caller can log a fail-loud trace (a dropped completion is a real
|
|
26
|
+
* loss; it must be visible, not silent).
|
|
27
|
+
*/
|
|
28
|
+
export declare function expireStaleUnresolvedDelegateForwards(nowMs?: number): number;
|
|
29
|
+
/** Test helper: purge the entire outbox. */
|
|
30
|
+
export declare function __clearUnresolvedDelegateForwardOutboxForTests(): void;
|
|
@@ -132,6 +132,20 @@ export declare class SpecCliAdapter implements CliAdapter {
|
|
|
132
132
|
*/
|
|
133
133
|
private maybeClearResolvedClaudeTuiPrompt;
|
|
134
134
|
private maybeCaptureClaudeTuiPrompt;
|
|
135
|
+
/**
|
|
136
|
+
* The TUI prompt is captured on the FIRST frame that renders the
|
|
137
|
+
* "Enter to select" footer. At that instant the option rows' checkbox
|
|
138
|
+
* column may not have drawn yet, so `detectClaudeTuiMultiSelect` returns
|
|
139
|
+
* false and the prompt is frozen as single-select — the dashboard then
|
|
140
|
+
* renders radio buttons even though the picker is multi-select.
|
|
141
|
+
*
|
|
142
|
+
* While the same TUI prompt is still on screen, re-check the live snapshot:
|
|
143
|
+
* if checkbox glyphs have since appeared, promote any single-select
|
|
144
|
+
* question to multi-select and re-emit status. Promotion is one-way
|
|
145
|
+
* (false→true only) — once a question is known multi-select we never demote
|
|
146
|
+
* it, since the glyph column can scroll out of view on later frames.
|
|
147
|
+
*/
|
|
148
|
+
private maybeUpgradeClaudeTuiMultiSelect;
|
|
135
149
|
private readClaudeTuiHeaders;
|
|
136
150
|
private captureClaudeTuiPrompt;
|
|
137
151
|
getDebugState(): Record<string, any>;
|
|
@@ -33,6 +33,25 @@ export interface ClaudeInteractiveTuiPage {
|
|
|
33
33
|
screenText: string;
|
|
34
34
|
header?: string;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Decide whether a captured claude-cli AskUserQuestion TUI page is multi-select.
|
|
38
|
+
*
|
|
39
|
+
* The original heuristic only matched the footer hint `/Space to select|toggle
|
|
40
|
+
* selections/i`. That string drifts between claude-cli versions, so when it
|
|
41
|
+
* changed the dashboard silently fell back to multiSelect:false and rendered
|
|
42
|
+
* single-select (radio) controls even though the on-screen picker showed
|
|
43
|
+
* checkboxes — the user could not check more than one box. (The CLI's own
|
|
44
|
+
* terminal still rendered `[ ]` correctly because it never depends on this
|
|
45
|
+
* parse.)
|
|
46
|
+
*
|
|
47
|
+
* Make detection robust by ALSO recognising the actual checkbox markers the
|
|
48
|
+
* multi-select picker draws on its option rows (`[ ]` / `[x]` / `☐` / `☒` /
|
|
49
|
+
* `◻` / `◼`). Single-select rows are drawn with a `❯`/number cursor only and
|
|
50
|
+
* carry none of these box glyphs, so their presence is a reliable signal. The
|
|
51
|
+
* broadened footer patterns ("Space to", "toggle", "select multiple") are kept
|
|
52
|
+
* as a secondary signal for layouts that render markers differently.
|
|
53
|
+
*/
|
|
54
|
+
export declare function detectClaudeTuiMultiSelect(screenText: string): boolean;
|
|
36
55
|
export declare function detectClaudeAskUserQuestionPromptFromTuiPages(pages: ClaudeInteractiveTuiPage[], options: {
|
|
37
56
|
promptId: string;
|
|
38
57
|
providerType?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.294",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.294",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
package/src/commands/router.ts
CHANGED
|
@@ -8267,6 +8267,13 @@ export class DaemonCommandRouter {
|
|
|
8267
8267
|
const meshHost = resolveMeshHostStatus(mesh);
|
|
8268
8268
|
|
|
8269
8269
|
const refreshRequested = args?.refresh === true || args?.forceRefresh === true;
|
|
8270
|
+
// Compact (default) elides each mission's full goal text from the
|
|
8271
|
+
// payload — coordinators polling node health don't need every
|
|
8272
|
+
// mission's multi-hundred-char goal repeated. verbose=true (or the
|
|
8273
|
+
// explicit compact=false) restores full goals. Verbose bypasses the
|
|
8274
|
+
// shared (compact) aggregate cache so a verbose call never poisons
|
|
8275
|
+
// the compact cache and vice versa.
|
|
8276
|
+
const verboseMissions = args?.verbose === true || args?.compact === false;
|
|
8270
8277
|
// See (B3) below: scope the peek to this daemon when the
|
|
8271
8278
|
// caller doesn't tell us, otherwise scoped events look
|
|
8272
8279
|
// missing and we falsely return a stale cache.
|
|
@@ -8275,7 +8282,7 @@ export class DaemonCommandRouter {
|
|
|
8275
8282
|
: (this.deps.statusInstanceId || undefined);
|
|
8276
8283
|
const pendingCoordinatorEventCount = getPendingMeshCoordinatorEvents(meshId, peekScope).length;
|
|
8277
8284
|
const hadAggregateCache = this.aggregateMeshStatusCache.has(meshId);
|
|
8278
|
-
if (!refreshRequested && pendingCoordinatorEventCount === 0) {
|
|
8285
|
+
if (!refreshRequested && !verboseMissions && pendingCoordinatorEventCount === 0) {
|
|
8279
8286
|
const cachedStatus = this.getCachedAggregateMeshStatus(meshId, mesh, { requireDirectPeerTruth: args?.requireDirectPeerTruth === true });
|
|
8280
8287
|
if (cachedStatus) {
|
|
8281
8288
|
logRepoMeshStatusDebug('return_cached', {
|
|
@@ -8649,7 +8656,7 @@ export class DaemonCommandRouter {
|
|
|
8649
8656
|
liveSessionRecords: liveMeshSessions,
|
|
8650
8657
|
});
|
|
8651
8658
|
const { getMeshStatusMissionSummaries } = await import('../mesh/mesh-missions.js');
|
|
8652
|
-
const missions = getMeshStatusMissionSummaries(meshId);
|
|
8659
|
+
const missions = getMeshStatusMissionSummaries(meshId, { verbose: verboseMissions });
|
|
8653
8660
|
const statusResult = {
|
|
8654
8661
|
success: true,
|
|
8655
8662
|
meshId: mesh.id,
|
|
@@ -8709,7 +8716,12 @@ export class DaemonCommandRouter {
|
|
|
8709
8716
|
})),
|
|
8710
8717
|
};
|
|
8711
8718
|
const { pendingCoordinatorEvents: _pendingCoordinatorEvents, unroutableDeliveries: _unroutableDeliveries, ...cacheableStatusResult } = statusResult as any;
|
|
8712
|
-
|
|
8719
|
+
// Verbose carries full mission goals; never store it in the shared
|
|
8720
|
+
// (compact) aggregate cache or a later compact poll would return the
|
|
8721
|
+
// heavy goals from cache. Return it without caching.
|
|
8722
|
+
const rememberedStatus = verboseMissions
|
|
8723
|
+
? cacheableStatusResult
|
|
8724
|
+
: this.rememberAggregateMeshStatus(meshId, cacheableStatusResult, refreshReason);
|
|
8713
8725
|
const returnedStatus = {
|
|
8714
8726
|
...rememberedStatus,
|
|
8715
8727
|
...(pendingCoordinatorEvents.length > 0 ? { pendingCoordinatorEvents } : {}),
|
|
@@ -1234,6 +1234,241 @@ function isBoundedTailRequest(limit: number, offset: number, excludeRecentCount:
|
|
|
1234
1234
|
return true;
|
|
1235
1235
|
}
|
|
1236
1236
|
|
|
1237
|
+
// Byte threshold below which a file is small enough that reading the whole
|
|
1238
|
+
// thing is cheaper than seeking. Reverse-seek pays off only on large files.
|
|
1239
|
+
const REVERSE_TAIL_SMALL_FILE_BYTES = 64 * 1024;
|
|
1240
|
+
// Chunk size for backward reads. We read the file tail one chunk at a time
|
|
1241
|
+
// (newest bytes first) until we have collected enough complete lines.
|
|
1242
|
+
const REVERSE_TAIL_CHUNK_BYTES = 64 * 1024;
|
|
1243
|
+
|
|
1244
|
+
// Per-(file path) incremental tail cache. A hot session's daily JSONL file grows
|
|
1245
|
+
// append-only while it generates; the size+mtime signature on the bounded-tail
|
|
1246
|
+
// read cache therefore invalidates on every append and forces a full re-read.
|
|
1247
|
+
// Here we keep the most recently decoded tail LINES for a file plus the byte
|
|
1248
|
+
// length we read them from. When the file has only grown (append-only: size
|
|
1249
|
+
// increased, the previously-read prefix is unchanged) we read just the new bytes
|
|
1250
|
+
// from `size` onward and splice them onto the retained tail — no full re-parse.
|
|
1251
|
+
// Truncation/rotation (size shrank, or a fresh inode) drops the entry and falls
|
|
1252
|
+
// back to a full reverse-seek.
|
|
1253
|
+
interface IncrementalTailCacheEntry {
|
|
1254
|
+
// File length (bytes) we have already consumed into `lines`.
|
|
1255
|
+
size: number;
|
|
1256
|
+
mtimeMs: number;
|
|
1257
|
+
// Decoded complete lines (oldest-first) covering at least the tail window.
|
|
1258
|
+
// Bounded to TAIL_LINES_RETAINED so memory stays flat for huge files.
|
|
1259
|
+
lines: string[];
|
|
1260
|
+
// True when `lines` is the entire file (head reached), so older pages can
|
|
1261
|
+
// trust that nothing precedes the retained window.
|
|
1262
|
+
coversWholeFile: boolean;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// How many trailing lines we retain per file. The bounded-tail caller never
|
|
1266
|
+
// asks for more than BOUNDED_TAIL_MAX_LIMIT + slack; keep a generous multiple so
|
|
1267
|
+
// repeated reads at the same window are served incrementally.
|
|
1268
|
+
const TAIL_LINES_RETAINED = BOUNDED_TAIL_MAX_LIMIT + 2 * BOUNDED_TAIL_SLACK;
|
|
1269
|
+
const INCREMENTAL_TAIL_CACHE_MAX_ENTRIES = 64;
|
|
1270
|
+
const incrementalTailCache = new Map<string, IncrementalTailCacheEntry>();
|
|
1271
|
+
|
|
1272
|
+
function evictIncrementalTailCache(): void {
|
|
1273
|
+
while (incrementalTailCache.size > INCREMENTAL_TAIL_CACHE_MAX_ENTRIES) {
|
|
1274
|
+
const oldest = incrementalTailCache.keys().next().value;
|
|
1275
|
+
if (oldest === undefined) break;
|
|
1276
|
+
incrementalTailCache.delete(oldest);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Split a Buffer into complete lines plus a leftover head fragment, partitioning
|
|
1281
|
+
// only on the newline byte (0x0A). 0x0A never appears inside a multibyte UTF-8
|
|
1282
|
+
// sequence, so decoding each complete byte segment is boundary-safe. The leftover
|
|
1283
|
+
// (bytes before the first newline) is returned undecoded so a caller stitching
|
|
1284
|
+
// chunks together never splits a multibyte char.
|
|
1285
|
+
function splitBufferLines(buf: Buffer): { head: Buffer; lines: string[] } {
|
|
1286
|
+
const lines: string[] = [];
|
|
1287
|
+
let lineEnd = buf.length;
|
|
1288
|
+
let firstNewline = -1;
|
|
1289
|
+
for (let i = buf.length - 1; i >= 0; i--) {
|
|
1290
|
+
if (buf[i] !== 0x0a) continue;
|
|
1291
|
+
if (i + 1 < lineEnd) {
|
|
1292
|
+
lines.push(buf.toString('utf-8', i + 1, lineEnd));
|
|
1293
|
+
}
|
|
1294
|
+
lineEnd = i;
|
|
1295
|
+
firstNewline = i;
|
|
1296
|
+
}
|
|
1297
|
+
// Lines were collected newest-first; restore oldest-first for the segment
|
|
1298
|
+
// that follows the first (lowest-index) newline.
|
|
1299
|
+
lines.reverse();
|
|
1300
|
+
const head = firstNewline >= 0 ? buf.subarray(0, firstNewline) : buf;
|
|
1301
|
+
return { head, lines };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Read the last bytes of a file, newest-first, until we have at least `needed`
|
|
1305
|
+
// complete lines (or reach the start of the file). Returns lines oldest-first and
|
|
1306
|
+
// whether the whole file was consumed. Boundary-safe: lines are cut on the
|
|
1307
|
+
// newline byte only, so multibyte UTF-8 chars are never split, and a trailing
|
|
1308
|
+
// partial line (no terminating newline) is preserved as a complete final line.
|
|
1309
|
+
function readReverseTailLines(filePath: string, needed: number): { lines: string[]; coversWholeFile: boolean; size: number; mtimeMs: number } {
|
|
1310
|
+
const fd = fs.openSync(filePath, 'r');
|
|
1311
|
+
try {
|
|
1312
|
+
const stat = fs.fstatSync(fd);
|
|
1313
|
+
const size = stat.size;
|
|
1314
|
+
let position = size;
|
|
1315
|
+
// `carry` holds bytes belonging to a line that straddles the current
|
|
1316
|
+
// chunk boundary (its start is in an older, not-yet-read chunk).
|
|
1317
|
+
let carry: Buffer = Buffer.alloc(0);
|
|
1318
|
+
const collected: string[] = [];
|
|
1319
|
+
|
|
1320
|
+
while (position > 0 && collected.length < needed) {
|
|
1321
|
+
const chunkSize = Math.min(REVERSE_TAIL_CHUNK_BYTES, position);
|
|
1322
|
+
position -= chunkSize;
|
|
1323
|
+
const chunk = Buffer.alloc(chunkSize);
|
|
1324
|
+
fs.readSync(fd, chunk, 0, chunkSize, position);
|
|
1325
|
+
const combined = carry.length ? Buffer.concat([chunk, carry]) : chunk;
|
|
1326
|
+
const { head, lines } = splitBufferLines(combined);
|
|
1327
|
+
// `head` is the (possibly partial) line whose start lies further back;
|
|
1328
|
+
// hold it for the next (older) chunk to complete.
|
|
1329
|
+
carry = head;
|
|
1330
|
+
// `lines` are oldest-first within this combined buffer; prepend them
|
|
1331
|
+
// ahead of what we already collected (which is strictly newer).
|
|
1332
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1333
|
+
collected.push(lines[i]);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const reachedStart = position <= 0;
|
|
1338
|
+
if (reachedStart && carry.length) {
|
|
1339
|
+
// Leftover head at the start of the file is itself a complete line.
|
|
1340
|
+
collected.push(carry.toString('utf-8'));
|
|
1341
|
+
}
|
|
1342
|
+
// `collected` is newest-first; restore oldest-first.
|
|
1343
|
+
collected.reverse();
|
|
1344
|
+
return { lines: collected, coversWholeFile: reachedStart, size, mtimeMs: stat.mtimeMs };
|
|
1345
|
+
} finally {
|
|
1346
|
+
fs.closeSync(fd);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// Return the tail lines (oldest-first) for a single history file, reading as
|
|
1351
|
+
// little of the file as possible. Strategy:
|
|
1352
|
+
// - Small files: one readFileSync (seeking is not worth the syscalls).
|
|
1353
|
+
// - Large files: reverse byte-seek for the newest `needed` lines.
|
|
1354
|
+
// - Append-only growth since the last read: read only the appended bytes and
|
|
1355
|
+
// splice them onto the retained tail (no full re-parse) — this is what keeps
|
|
1356
|
+
// a hot, still-generating session cheap to poll.
|
|
1357
|
+
// `needed` is a soft floor; we may return more (whole small files / retained
|
|
1358
|
+
// window). Lines include any trailing partial (unterminated) final line.
|
|
1359
|
+
function readFileTailLines(filePath: string, needed: number): { lines: string[]; coversWholeFile: boolean } {
|
|
1360
|
+
let stat: fs.Stats;
|
|
1361
|
+
try {
|
|
1362
|
+
stat = fs.statSync(filePath);
|
|
1363
|
+
} catch {
|
|
1364
|
+
return { lines: [], coversWholeFile: true };
|
|
1365
|
+
}
|
|
1366
|
+
const size = stat.size;
|
|
1367
|
+
const mtimeMs = stat.mtimeMs;
|
|
1368
|
+
if (size === 0) {
|
|
1369
|
+
incrementalTailCache.delete(filePath);
|
|
1370
|
+
return { lines: [], coversWholeFile: true };
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const cached = incrementalTailCache.get(filePath);
|
|
1374
|
+
if (cached) {
|
|
1375
|
+
if (cached.size === size && cached.mtimeMs === mtimeMs) {
|
|
1376
|
+
// Unchanged since last read — reuse retained tail. Refresh LRU.
|
|
1377
|
+
incrementalTailCache.delete(filePath);
|
|
1378
|
+
incrementalTailCache.set(filePath, cached);
|
|
1379
|
+
if (cached.coversWholeFile || cached.lines.length >= needed) {
|
|
1380
|
+
return { lines: cached.lines, coversWholeFile: cached.coversWholeFile };
|
|
1381
|
+
}
|
|
1382
|
+
// Retained window is smaller than this request needs; fall through
|
|
1383
|
+
// to a fresh reverse-seek for the larger window.
|
|
1384
|
+
} else if (size > cached.size) {
|
|
1385
|
+
// Append-only growth: the prefix [0, cached.size) is assumed
|
|
1386
|
+
// unchanged (JSONL is append-only). Read just the new bytes and
|
|
1387
|
+
// stitch them — but verify the byte at cached.size-1 is still the
|
|
1388
|
+
// newline that terminated our last retained line, so a rewrite that
|
|
1389
|
+
// happens to grow the file (compaction) is detected and rejected.
|
|
1390
|
+
const incremental = tryIncrementalTailGrowth(filePath, cached, size, mtimeMs, needed);
|
|
1391
|
+
if (incremental) return { lines: incremental.lines, coversWholeFile: incremental.coversWholeFile };
|
|
1392
|
+
}
|
|
1393
|
+
// size shrank (truncation/rotation) or incremental failed → drop & reload.
|
|
1394
|
+
incrementalTailCache.delete(filePath);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
if (size <= REVERSE_TAIL_SMALL_FILE_BYTES) {
|
|
1398
|
+
let content: string;
|
|
1399
|
+
try {
|
|
1400
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
1401
|
+
} catch {
|
|
1402
|
+
return { lines: [], coversWholeFile: true };
|
|
1403
|
+
}
|
|
1404
|
+
const lines = content.split('\n');
|
|
1405
|
+
// A trailing newline yields a final empty element; drop only that one so
|
|
1406
|
+
// an unterminated partial last line is still preserved.
|
|
1407
|
+
if (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
1408
|
+
storeIncrementalTailCache(filePath, size, mtimeMs, lines, true);
|
|
1409
|
+
return { lines, coversWholeFile: true };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
let result: { lines: string[]; coversWholeFile: boolean; size: number; mtimeMs: number };
|
|
1413
|
+
try {
|
|
1414
|
+
result = readReverseTailLines(filePath, needed);
|
|
1415
|
+
} catch {
|
|
1416
|
+
return { lines: [], coversWholeFile: true };
|
|
1417
|
+
}
|
|
1418
|
+
storeIncrementalTailCache(filePath, result.size, result.mtimeMs, result.lines, result.coversWholeFile);
|
|
1419
|
+
return { lines: result.lines, coversWholeFile: result.coversWholeFile };
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Read appended bytes [cached.size, size) and splice them onto the retained tail.
|
|
1423
|
+
// Returns null if the prior byte is not a newline (the retained tail did not end
|
|
1424
|
+
// on a record boundary, e.g. the file was rewritten) so the caller can full-reload.
|
|
1425
|
+
function tryIncrementalTailGrowth(
|
|
1426
|
+
filePath: string,
|
|
1427
|
+
cached: IncrementalTailCacheEntry,
|
|
1428
|
+
size: number,
|
|
1429
|
+
mtimeMs: number,
|
|
1430
|
+
needed: number,
|
|
1431
|
+
): { lines: string[]; coversWholeFile: boolean } | null {
|
|
1432
|
+
const fd = fs.openSync(filePath, 'r');
|
|
1433
|
+
try {
|
|
1434
|
+
// Confirm the byte ending the previously-read prefix is still a newline.
|
|
1435
|
+
if (cached.size > 0) {
|
|
1436
|
+
const boundary = Buffer.alloc(1);
|
|
1437
|
+
fs.readSync(fd, boundary, 0, 1, cached.size - 1);
|
|
1438
|
+
if (boundary[0] !== 0x0a) return null;
|
|
1439
|
+
}
|
|
1440
|
+
const appendedLength = size - cached.size;
|
|
1441
|
+
const appended = Buffer.alloc(appendedLength);
|
|
1442
|
+
fs.readSync(fd, appended, 0, appendedLength, cached.size);
|
|
1443
|
+
const newLines = appended.toString('utf-8').split('\n');
|
|
1444
|
+
if (newLines.length && newLines[newLines.length - 1] === '') newLines.pop();
|
|
1445
|
+
const merged = cached.lines.concat(newLines);
|
|
1446
|
+
// Keep memory flat: retain only the trailing window.
|
|
1447
|
+
const trimmed = merged.length > TAIL_LINES_RETAINED
|
|
1448
|
+
? merged.slice(merged.length - TAIL_LINES_RETAINED)
|
|
1449
|
+
: merged;
|
|
1450
|
+
const coversWholeFile = cached.coversWholeFile && trimmed.length === merged.length;
|
|
1451
|
+
storeIncrementalTailCache(filePath, size, mtimeMs, trimmed, coversWholeFile);
|
|
1452
|
+
if (coversWholeFile || trimmed.length >= needed) {
|
|
1453
|
+
return { lines: trimmed, coversWholeFile };
|
|
1454
|
+
}
|
|
1455
|
+
// Should not happen (we only grew), but be safe.
|
|
1456
|
+
return { lines: trimmed, coversWholeFile };
|
|
1457
|
+
} catch {
|
|
1458
|
+
return null;
|
|
1459
|
+
} finally {
|
|
1460
|
+
fs.closeSync(fd);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function storeIncrementalTailCache(filePath: string, size: number, mtimeMs: number, lines: string[], coversWholeFile: boolean): void {
|
|
1465
|
+
const retained = lines.length > TAIL_LINES_RETAINED ? lines.slice(lines.length - TAIL_LINES_RETAINED) : lines;
|
|
1466
|
+
const covers = coversWholeFile && retained.length === lines.length;
|
|
1467
|
+
incrementalTailCache.delete(filePath);
|
|
1468
|
+
incrementalTailCache.set(filePath, { size, mtimeMs, lines: retained, coversWholeFile: covers });
|
|
1469
|
+
evictIncrementalTailCache();
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1237
1472
|
// Read newest-first only as many files as needed to cover the requested window
|
|
1238
1473
|
// plus slack. listHistoryFiles already returns files reversed (newest-first), so
|
|
1239
1474
|
// we accumulate (de-duped) candidates from the end and stop once we have enough,
|
|
@@ -1250,19 +1485,22 @@ function readBoundedTailRecords(
|
|
|
1250
1485
|
|
|
1251
1486
|
for (let f = 0; f < files.length; f++) {
|
|
1252
1487
|
const filePath = path.join(dir, files[f]);
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
//
|
|
1488
|
+
// Read only the file tail needed to top up the window — for a large
|
|
1489
|
+
// single-day file this seeks the last `needed` lines instead of parsing
|
|
1490
|
+
// the whole file. We re-derive the per-file floor each iteration from how
|
|
1491
|
+
// many records are still missing (plus slack so dedup at the boundary is
|
|
1492
|
+
// stable), capped at `needed`.
|
|
1493
|
+
const remaining = Math.max(0, needed - collected.length);
|
|
1494
|
+
const perFileNeeded = Math.min(needed, remaining + BOUNDED_TAIL_SLACK);
|
|
1495
|
+
const { lines, coversWholeFile } = readFileTailLines(filePath, perFileNeeded);
|
|
1496
|
+
// Walk this file's tail lines newest-first so we fill the tail window from
|
|
1497
|
+
// the bottom. seen-dedup keeps the same first-wins-by-newest semantics the
|
|
1262
1498
|
// full read produced (files are processed newest-first there too).
|
|
1263
1499
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1500
|
+
const line = lines[i];
|
|
1501
|
+
if (!line) continue;
|
|
1264
1502
|
try {
|
|
1265
|
-
const parsed = JSON.parse(
|
|
1503
|
+
const parsed = JSON.parse(line) as HistoryMessage;
|
|
1266
1504
|
const sanitizedMessage = sanitizeHistoryMessage(agentType, parsed);
|
|
1267
1505
|
if (!sanitizedMessage) continue;
|
|
1268
1506
|
const hash = buildHistoryMessageHash(agentType, sanitizedMessage);
|
|
@@ -1271,6 +1509,13 @@ function readBoundedTailRecords(
|
|
|
1271
1509
|
collected.push(sanitizedMessage);
|
|
1272
1510
|
} catch { /* skip invalid lines */ }
|
|
1273
1511
|
}
|
|
1512
|
+
// If we only read this file's tail (its head was not reached), older
|
|
1513
|
+
// messages remain within this very file — the conversation is NOT fully
|
|
1514
|
+
// represented even if this is the last file, so hasMore must stay true.
|
|
1515
|
+
if (!coversWholeFile) {
|
|
1516
|
+
readAllFiles = false;
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1274
1519
|
// Stop once we have the window AND there is at least one more file (so a
|
|
1275
1520
|
// potential older boundary message exists). If this is the last file we
|
|
1276
1521
|
// fall through and mark the whole history as read.
|
package/src/git/git-status.ts
CHANGED
|
@@ -143,11 +143,39 @@ const WEB_ONLY_PACKAGES = new Set([
|
|
|
143
143
|
'terminal-render-web',
|
|
144
144
|
]);
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Root-level (non-package) files that demonstrably cannot change what the daemon
|
|
148
|
+
* runtime executes: convergence/verify markers, documentation, and license/notice
|
|
149
|
+
* text. A root commit that moves the oss gitlink while the oss commit only touched
|
|
150
|
+
* one of these is NOT a reason to rebuild/restart the daemon — flagging it produces
|
|
151
|
+
* the staleDaemonBuild false-positive this guard exists to suppress.
|
|
152
|
+
*
|
|
153
|
+
* Deliberately conservative: anything NOT matched here (root config like
|
|
154
|
+
* package.json / tsconfig / build scripts, `.txt` fixtures, lockfiles, unknown
|
|
155
|
+
* dotfiles) stays daemon-affecting, because a false-negative — staying silent when
|
|
156
|
+
* the daemon really IS stale — is worse than an over-warn. Markers are the common
|
|
157
|
+
* real-world case (e.g. `.verify-patch-equiv-rc292`), so they are matched broadly;
|
|
158
|
+
* docs are matched by the `docs/` prefix or a markdown/text-doc extension at a
|
|
159
|
+
* filename we recognize as documentation (README/CHANGELOG/LICENSE/NOTICE).
|
|
160
|
+
*/
|
|
161
|
+
function isNonRuntimeRootFile(file: string): boolean {
|
|
162
|
+
const base = file.slice(file.lastIndexOf('/') + 1);
|
|
163
|
+
// Verify/convergence markers: dotfiles whose name signals a transient marker.
|
|
164
|
+
if (/^\.(?:verify|marker|converge|ff-verify|patch-equiv|live-verify)\b/i.test(base)) return true;
|
|
165
|
+
// Documentation living under a docs/ tree (any depth, root or nested).
|
|
166
|
+
if (/(?:^|\/)docs\//i.test(file)) return true;
|
|
167
|
+
// Recognized top-level documentation / license files.
|
|
168
|
+
if (/^(?:README|CHANGELOG|LICENSE|NOTICE|AUTHORS|CONTRIBUTING|CODEOWNERS)(?:\.[A-Za-z0-9]+)?$/i.test(base)) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
146
174
|
/**
|
|
147
175
|
* Determine whether the changes between buildCommit..HEAD touch any daemon-runtime
|
|
148
176
|
* package. Returns isDaemonAffecting:true conservatively when the changed-file set
|
|
149
177
|
* can't be obtained or any changed path is outside the known web-only package set
|
|
150
|
-
*
|
|
178
|
+
* AND is not a recognized non-runtime root file (marker/doc/license).
|
|
151
179
|
*/
|
|
152
180
|
async function classifyDaemonBuildChange(
|
|
153
181
|
repoPath: string,
|
|
@@ -166,24 +194,28 @@ async function classifyDaemonBuildChange(
|
|
|
166
194
|
return { isDaemonAffecting: true, affectedPackages: [] };
|
|
167
195
|
}
|
|
168
196
|
const pkgs = new Set<string>();
|
|
169
|
-
|
|
197
|
+
// A non-package path that is NOT a recognized benign root file (marker/doc).
|
|
198
|
+
// Only these force daemon-affecting; benign markers/docs are ignored so a
|
|
199
|
+
// gitlink-moving root commit over a marker-only oss commit no longer over-warns.
|
|
200
|
+
let sawRuntimeAmbiguousNonPackage = false;
|
|
170
201
|
for (const file of files) {
|
|
171
202
|
const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
|
|
172
203
|
if (!match) {
|
|
173
|
-
|
|
204
|
+
if (!isNonRuntimeRootFile(file)) sawRuntimeAmbiguousNonPackage = true;
|
|
174
205
|
continue;
|
|
175
206
|
}
|
|
176
207
|
pkgs.add(match[1]);
|
|
177
208
|
}
|
|
178
209
|
const affectedPackages = [...pkgs].sort();
|
|
179
|
-
// Daemon-affecting if: any non-package
|
|
180
|
-
// changed, or any explicit daemon-runtime package changed.
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
210
|
+
// Daemon-affecting if: any runtime-ambiguous non-package file changed, any
|
|
211
|
+
// unknown package changed, or any explicit daemon-runtime package changed.
|
|
212
|
+
// The daemon is unaffected only when every changed file is either a known
|
|
213
|
+
// web-only package or a recognized benign root file (and at least one such
|
|
214
|
+
// file changed) — i.e. nothing runtime-ambiguous remains.
|
|
215
|
+
const allBenign =
|
|
216
|
+
!sawRuntimeAmbiguousNonPackage &&
|
|
185
217
|
affectedPackages.every((p) => WEB_ONLY_PACKAGES.has(p) && !DAEMON_RUNTIME_PACKAGES.has(p));
|
|
186
|
-
return { isDaemonAffecting: !
|
|
218
|
+
return { isDaemonAffecting: !allBenign, affectedPackages };
|
|
187
219
|
} catch {
|
|
188
220
|
// diff probe failed → can't prove web-only; stay conservative.
|
|
189
221
|
return { isDaemonAffecting: true, affectedPackages: [] };
|
|
@@ -229,11 +261,14 @@ async function detectDaemonBuildBehind(
|
|
|
229
261
|
options,
|
|
230
262
|
);
|
|
231
263
|
const scopeLabel = scope === 'root' ? 'workspace' : scope;
|
|
264
|
+
const benignDetail = affectedPackages.length > 0
|
|
265
|
+
? `only web packages changed (${affectedPackages.join(', ')})`
|
|
266
|
+
: 'only non-runtime files changed (markers/docs)';
|
|
232
267
|
const warning = isDaemonAffecting
|
|
233
268
|
? `Live daemon was built from ${build.commitShort} which is behind ${scopeLabel} HEAD ${head.slice(0, 7)}. ` +
|
|
234
269
|
`Merged code is NOT live until the daemon is rebuilt/redeployed and restarted — a local dist rebuild alone does not update a cloud daemon.`
|
|
235
270
|
: `Live daemon was built from ${build.commitShort} which is behind ${scopeLabel} HEAD ${head.slice(0, 7)}, ` +
|
|
236
|
-
`but
|
|
271
|
+
`but ${benignDetail}. ` +
|
|
237
272
|
`Daemon restart NOT required — redeploy the web app to reflect the change.`;
|
|
238
273
|
return {
|
|
239
274
|
buildCommit: build.commit,
|
|
@@ -350,7 +350,7 @@ const WORKFLOW_SECTION = `## Orchestration Workflow
|
|
|
350
350
|
c. **Targeted Tasks**: Use \`mesh_send_task\` only when you need to bypass the queue and force a specific node to execute a task immediately.
|
|
351
351
|
d. For the first dispatch of a new task, provide a **complete, self-contained** instruction that includes all context the agent needs (file paths, line numbers, what to change, why). Do not send partial instructions expecting future follow-up.
|
|
352
352
|
e. For a continuation of the same issue in an existing session, send a concise **delta instruction**: current verified state, the exact failed/blocked step, the newly approved action, and final reporting requirements. Do not resend the full original task or open a new chat solely to continue the same work; that wastes coordinator and worker context.
|
|
353
|
-
4. **Monitor** — Prefer event-driven completion/status notifications. Do **not** poll \`mesh_read_chat\` repeatedly. Do **not** repeatedly call \`mesh_status\` or \`mesh_view_queue\` just to wait for assigned/generating work. After dispatching a direct or queued task, send one progress update with the task/session handle, then stop. Wait for \`pendingCoordinatorEvents\` or another completion/approval/status signal, an explicit user status request, or a real timeout/stall signal before reading status/chat/queue again. Use at most one compact \`mesh_read_chat\` check after a terminal signal. Handle approvals via \`mesh_approve\`.
|
|
353
|
+
4. **Monitor** — Prefer event-driven completion/status notifications. Do **not** poll \`mesh_read_chat\` repeatedly. Do **not** repeatedly call \`mesh_status\` or \`mesh_view_queue\` just to wait for assigned/generating work. After dispatching a direct or queued task, send one progress update with the task/session handle, then stop. Wait for \`pendingCoordinatorEvents\` or another completion/approval/status signal, an explicit user status request, or a real timeout/stall signal before reading status/chat/queue again. Use at most one compact \`mesh_read_chat\` check after a terminal signal. Handle approvals via \`mesh_approve\`. **Proactively parallelize new work.** When the user reports a new bug or asks for new work, start it immediately if it is independent of in-flight tasks and there is headroom under \`maxParallelTasks\` — do not wait for a current task to finish or for the user to prompt you to parallelize. Read-only diagnosis (\`live_debug_readonly\`) has no isolation or merge cost, so dispatch it in parallel right away. The no-polling / concurrency-limit rules constrain *re-checking or duplicating already-dispatched work*; they are **not** a reason to defer starting a new, independent task.
|
|
354
354
|
5. **Verify** — When a task reports completion or git work is visible, call \`mesh_git_status\` to verify changes were made.
|
|
355
355
|
6. **Checkpoint** — Call \`mesh_checkpoint\` to save the work.
|
|
356
356
|
7. **Converge branches** — Before marking any task complete, classify every touched node/branch into exactly one final state: \`merged_to_main\`, \`pushed_feature_branch_needs_merge\`, \`blocked_review\`, \`cleanup_candidate\`, or \`not_mergeable\`. Use \`mesh_status\` branchConvergenceSummary. For obvious clean branch catch-up (ahead 0, behind > 0, upstream fresh, no dirty/stash/submodule issues), use \`mesh_fast_forward_node\` dry-run first and execute only when explicitly safe/approved; this avoids consuming an agent session. Use \`mesh_refine_node\` for clean worktree branches when safe. Before/refine merging root commits that contain submodule gitlink changes, require each submodule commit to be reachable from the configured submodule remote main branch, not merely present on a feature ref or local checkout. If \`mesh_refine_node\` returns \`submodule_reachability_failed\` or publish-required evidence, keep the public convergence bucket as \`blocked_review\`; unless \`allowAutoPublishSubmoduleMainCommits\` is explicitly enabled and Refinery reports successful non-force publish plus post-publish verification, ask the user for explicit approval to push/publish the unreachable submodule commit(s) to submodule main, then rerun \`mesh_refine_node\`. Do not merge the root branch until the submodule commit(s) are reachable from submodule origin/main. A task that remains on a non-main branch is not fully complete unless the final report names the follow-up state and next step.
|
|
@@ -382,8 +382,9 @@ function buildRulesSection(coordinatorCliType?: string): string {
|
|
|
382
382
|
- **Reuse idle sessions.** For follow-up, retry, commit/push, or cleanup on the same issue, send only the delta to the existing idle session. Start fresh only for independent work, provider mismatch, transcript contamination, or required worktree isolation.
|
|
383
383
|
- **Respect explicit provider requests.** Map: Hermes → \`hermes-cli\`, Claude/Claude Code → \`claude-cli\`, Codex → \`codex-cli\`, Gemini → \`gemini-cli\`, Antigravity → \`antigravity-cli\`. Never substitute the coordinator's own runtime.
|
|
384
384
|
- **Verify via git, not source.** Use \`mesh_git_status\` to confirm side effects. Treat agent summaries as self-reports, not verification.
|
|
385
|
-
- **Limit parallelism.** Start with 1–2 tasks; scale only on success. Never duplicate a session because \`mesh_read_chat\` shows no final message while tool/terminal activity is ongoing.
|
|
385
|
+
- **Limit parallelism.** Start with 1–2 tasks; scale only on success. Never duplicate a session because \`mesh_read_chat\` shows no final message while tool/terminal activity is ongoing. This caps *concurrent* load — it does not mean serialize independent work: when a new, independent request arrives and there is headroom under \`maxParallelTasks\`, dispatch it right away rather than waiting for an in-flight task or a user nudge (read-only diagnosis especially, since it has no merge cost).
|
|
386
386
|
- **Check history first.** Call \`mesh_task_history\` at session start to avoid duplicate work and inform recovery. On failure, read task history before retrying.
|
|
387
|
+
- **Sequence shared-base-moving merges.** Parallel dispatch is encouraged, but merging one worktree can advance another in-flight worktree's base — especially the oss submodule pointer — turning a clean fast-forward into a diverged rebase (patch-equivalence correctly blocks this). Before merging an in-flight worktree while siblings are also in flight, land in an intentional order, re-clone long-running worktrees from the advanced base, or expect to manually rebase + ff-only the laggards; merging an independent fix mid-flight can strand siblings into a rebase.
|
|
387
388
|
- **Converge branches.** After worktree tasks: refine/fast-forward, or classify as \`pushed_feature_branch_needs_merge\` / \`blocked_review\` / \`cleanup_candidate\` / \`not_mergeable\`. Clean up with \`mesh_remove_node\`.
|
|
388
389
|
- **Refinery is config-driven.** \`mesh_refine_node\` must run validation from \`.adhdev/refine.{json,yaml,yml}\` or \`repo-mesh.refine.*\`. Heuristics are scaffolding only.
|
|
389
390
|
- **Submodule reachability = publish-needed.** \`submodule_reachability_failed\` → classify as \`blocked_review\`, request user approval to push to submodule main, then rerun \`mesh_refine_node\`.
|