agent-coord-mcp 0.26.12 → 0.26.14
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/server.js +86 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/away.js +180 -59
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/event-kinds.js +39 -0
- package/dist/tools/event-kinds.js.map +1 -0
- package/dist/tools/events.js +1 -1
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/record-events.js +56 -48
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +23 -2
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +10 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/stall.js +100 -8
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +19 -1
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +37 -1
- package/dist/tools/work.js.map +1 -1
- package/dist/tools/worktrees.js +46 -1
- package/dist/tools/worktrees.js.map +1 -1
- package/package.json +3 -3
- package/scripts/check-test-count.mjs +1 -1
- package/src/server.ts +90 -5
- package/src/tools/away.ts +221 -55
- package/src/tools/event-kinds.ts +60 -0
- package/src/tools/events.ts +1 -1
- package/src/tools/record-events.ts +56 -54
- package/src/tools/records.ts +23 -1
- package/src/tools/registry.ts +10 -1
- package/src/tools/stall.ts +104 -11
- package/src/tools/transport.ts +19 -1
- package/src/tools/work.ts +54 -2
- package/src/tools/worktrees.ts +45 -1
package/src/tools/stall.ts
CHANGED
|
@@ -93,9 +93,9 @@ export function markRunFailure(reason: string): void {
|
|
|
93
93
|
writeFileSync(runFile(), JSON.stringify({ history: history.slice(-200) }, null, 2));
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
type RunMark = { at: number; hits: number; checked: number; failed?: string };
|
|
96
|
+
type RunMark = { at: number; hits: number; checked: number; measurable?: number; failed?: string };
|
|
97
97
|
|
|
98
|
-
function markRun(result: { hits: StallHit[]; checked: number }) {
|
|
98
|
+
function markRun(result: { hits: StallHit[]; checked: number; measurable?: number }) {
|
|
99
99
|
mkdirSync(ROOT, { recursive: true });
|
|
100
100
|
let history: RunMark[] = [];
|
|
101
101
|
try {
|
|
@@ -103,7 +103,12 @@ function markRun(result: { hits: StallHit[]; checked: number }) {
|
|
|
103
103
|
} catch {
|
|
104
104
|
/* first run */
|
|
105
105
|
}
|
|
106
|
-
|
|
106
|
+
// COVERAGE IS RECORDED WITH THE RUN, because a status tool that cannot
|
|
107
|
+
// express its own blindness is worse than no status tool. Measured: the clock
|
|
108
|
+
// reported `runs 8, failures 0` — all green — while every one of those runs
|
|
109
|
+
// had covered 0 of 3 agents. Nothing in the mark could say so, and this is the
|
|
110
|
+
// instrument meant to cover an absence.
|
|
111
|
+
history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked, measurable: result.measurable ?? 0 });
|
|
107
112
|
// A RUN OF MISSES MUST BE VISIBLE AS RUNS, not as absence — so the marks are a
|
|
108
113
|
// list, not a single timestamp. "Ten quiet checks" and "one check ten hours ago"
|
|
109
114
|
// are different states and only the first is a healthy fleet.
|
|
@@ -114,7 +119,7 @@ export const lastRanSchema = { maxAgeMinutes: z.number().optional() };
|
|
|
114
119
|
|
|
115
120
|
/**
|
|
116
121
|
* Is the clock alive? Readable by a human or another check, which is the point —
|
|
117
|
-
* MISS is silent to the
|
|
122
|
+
* MISS is silent to the WATCHER, not to the record.
|
|
118
123
|
*/
|
|
119
124
|
export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
|
|
120
125
|
const maxAge = (args.maxAgeMinutes ?? 60) * 60 * 1000;
|
|
@@ -150,11 +155,19 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
|
|
|
150
155
|
const stale = age >= maxAge;
|
|
151
156
|
const misses = history.filter((h) => !h.failed && h.hits === 0).length;
|
|
152
157
|
const hits = history.filter((h) => !h.failed && h.hits > 0).length;
|
|
158
|
+
// A RUN THAT COVERED NOTHING IS NOT A RUN THAT FOUND NOTHING. Marks written
|
|
159
|
+
// before coverage was recorded carry no `measurable` field: they are reported
|
|
160
|
+
// as UNKNOWN rather than assumed covered, because assuming is what produced
|
|
161
|
+
// the green this exists to correct.
|
|
162
|
+
const graded = history.filter((h) => !h.failed && typeof h.measurable === "number");
|
|
163
|
+
const blindRuns = graded.filter((h) => h.checked > 0 && h.measurable === 0).length;
|
|
164
|
+
const ungraded = history.filter((h) => !h.failed && typeof h.measurable !== "number").length;
|
|
165
|
+
const lastBlind = last && !last.failed && (last.checked ?? 0) > 0 && last.measurable === 0;
|
|
153
166
|
return {
|
|
154
167
|
// A FAILING CLOCK IS NOT A HEALTHY ONE. It writes marks on schedule, so the
|
|
155
168
|
// age looks fresh while nothing is being measured — a fresh heartbeat from
|
|
156
169
|
// a stuck agent, one level up.
|
|
157
|
-
ok: !stale && !lastFailed,
|
|
170
|
+
ok: !stale && !lastFailed && !lastBlind,
|
|
158
171
|
...(stale
|
|
159
172
|
? {
|
|
160
173
|
error: `stall_check last ran ${Math.round(age / 60000)}m ago, past the ${args.maxAgeMinutes ?? 60}m window — THE CLOCK IS STOPPED. No alerts is not the same as no stalls.`,
|
|
@@ -163,12 +176,28 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
|
|
|
163
176
|
? {
|
|
164
177
|
error: `the clock is RUNNING but its last run FAILED: ${lastFailed}. It is firing on schedule and measuring nothing, which reads as fresh and is not.`,
|
|
165
178
|
}
|
|
166
|
-
:
|
|
179
|
+
: lastBlind
|
|
180
|
+
? {
|
|
181
|
+
error:
|
|
182
|
+
`the clock is RUNNING and BLIND: its last run checked ${last?.checked} in-flight row(s) and could measure ${last?.measurable} of them. ` +
|
|
183
|
+
`It fires on schedule, reports no hits, and that "no hits" is not evidence of a healthy fleet — it is evidence of nothing. ` +
|
|
184
|
+
`Read stall_check's own 'unmeasurable' list for the cause; the usual one is a board 'Branch · Worktree' cell holding a PATH rather than a branch ref.`,
|
|
185
|
+
}
|
|
186
|
+
: {}),
|
|
167
187
|
lastRanMinutesAgo: Math.round(age / 60000),
|
|
168
188
|
runs: history.length,
|
|
169
189
|
misses,
|
|
170
190
|
hits,
|
|
171
191
|
failures: failures.length,
|
|
192
|
+
// The number the old shape could not express.
|
|
193
|
+
coverage: {
|
|
194
|
+
lastRun: last && !last.failed ? { checked: last.checked, measurable: last.measurable ?? null } : null,
|
|
195
|
+
blindRuns,
|
|
196
|
+
ungraded,
|
|
197
|
+
note:
|
|
198
|
+
"blindRuns are runs that checked rows and measured none of them — green by every other field. " +
|
|
199
|
+
"ungraded are marks written before coverage was recorded: UNKNOWN, never assumed covered.",
|
|
200
|
+
},
|
|
172
201
|
};
|
|
173
202
|
}
|
|
174
203
|
|
|
@@ -190,10 +219,27 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
190
219
|
const hits: StallHit[] = [];
|
|
191
220
|
/** Rows whose VCS activity could not be measured. NOT stall claims. */
|
|
192
221
|
const unmeasurable: { agentId: string; value: string; why: string }[] = [];
|
|
222
|
+
/**
|
|
223
|
+
* Rows a predicate actually reached a VERDICT about — hit or clean.
|
|
224
|
+
*
|
|
225
|
+
* Tracked explicitly rather than inferred from "2 predicates minus the
|
|
226
|
+
* unmeasurable ones", because a predicate that is SKIPPED pushes no
|
|
227
|
+
* unmeasurable entry and the arithmetic then counts it as having measured.
|
|
228
|
+
* That is the same defect as the `continue` above, one level up: a silent
|
|
229
|
+
* skip and a clean pass produce the same number.
|
|
230
|
+
*/
|
|
231
|
+
const measured = new Set<string>();
|
|
193
232
|
|
|
194
233
|
for (const row of inFlight) {
|
|
195
234
|
const agentId = row.owner.replace(/[`*]/g, "").trim();
|
|
196
235
|
const entry = reg[agentId];
|
|
236
|
+
if (!entry) {
|
|
237
|
+
// NOT SKIPPED IN SILENCE. An owner with no registry entry has no
|
|
238
|
+
// heartbeat to read, which is a missing signal and must be reported as
|
|
239
|
+
// one — a row whose owner the bus has never heard of is exactly the row
|
|
240
|
+
// you would want named before going away.
|
|
241
|
+
unmeasurable.push({ agentId, value: "", why: "no registry entry for this owner — there is no heartbeat to read, and the name may not be an agent id at all" });
|
|
242
|
+
}
|
|
197
243
|
if (entry) {
|
|
198
244
|
const age = now - entry.lastHeartbeat;
|
|
199
245
|
// A HEARTBEAT IS ONLY EVIDENCE WHERE SOMETHING WRITES ONE.
|
|
@@ -230,12 +276,51 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
230
276
|
value: marker.transport,
|
|
231
277
|
why: `heartbeat is ${Math.round(age / 60000)}m old, but nothing writes heartbeats for a local 'tmux-push' transport — hooks/tmux-pusher.mjs does not call heartbeat, and this marker's liveness is its pid. There is no heartbeat SOURCE here, so the age measures nothing about this agent`,
|
|
232
278
|
});
|
|
233
|
-
continue
|
|
234
|
-
|
|
235
|
-
|
|
279
|
+
// FALL THROUGH TO THE VCS PREDICATE — do not `continue`.
|
|
280
|
+
//
|
|
281
|
+
// This used to skip the row entirely, and the consequence was total: on
|
|
282
|
+
// this fleet, 3 of 3 in-flight rows were local tmux-push agents, so
|
|
283
|
+
// EVERY row exited here and the check reported `checked 3 · measurable
|
|
284
|
+
// 0`. The half that was blind (heartbeat, deliberately, for the reason
|
|
285
|
+
// above) was taking the half that works (vcs activity) down with it.
|
|
286
|
+
//
|
|
287
|
+
// The two predicates answer different questions and only one of them is
|
|
288
|
+
// unanswerable for a local transport. "Is it alive" has no source here;
|
|
289
|
+
// "is its branch moving" has a perfectly good one, and it is the half
|
|
290
|
+
// that catches the case this verb exists for — alive and not
|
|
291
|
+
// progressing. An unmeasurable heartbeat is a missing signal, not a
|
|
292
|
+
// reason to stop measuring the signal that is present.
|
|
293
|
+
} else if (age > limit) {
|
|
236
294
|
hits.push({ kind: "no-heartbeat", agentId, stream: row.stream.slice(0, 60), minutes: Math.round(age / 60000) });
|
|
295
|
+
measured.add(agentId);
|
|
237
296
|
continue;
|
|
238
297
|
}
|
|
298
|
+
// A FRESH HEARTBEAT IS *NOT* A VERDICT, AND THIS IS A CORRECTION TO WHAT
|
|
299
|
+
// THIS CODE CLAIMED WHEN IT MERGED.
|
|
300
|
+
//
|
|
301
|
+
// It read "a fresh heartbeat IS a verdict — alive" and credited coverage
|
|
302
|
+
// for it. Measured since, by reading agents.json directly (`list_agents`
|
|
303
|
+
// refreshes the CALLER's mark, so it cannot be used to measure the
|
|
304
|
+
// caller): an agent's mark aged from 9611s to 9623s across a `post_status`
|
|
305
|
+
// call. BUS TOOL CALLS DO NOT WRITE HEARTBEATS.
|
|
306
|
+
//
|
|
307
|
+
// `heartbeat` age is TIME SINCE JOIN. Not activity, and not even
|
|
308
|
+
// time-since-restart — a server can restart mid-session without the mark
|
|
309
|
+
// moving, because nothing rejoined. So freshness says "recently joined",
|
|
310
|
+
// and crediting it as coverage lets `/coord-away` arm on a fleet whose
|
|
311
|
+
// only evidence is that somebody reconnected.
|
|
312
|
+
//
|
|
313
|
+
// The inverse is worse and is why this is not merely tidiness: reading
|
|
314
|
+
// age as activity marks the two most CONTINUOUSLY ACTIVE agents on a bus
|
|
315
|
+
// as stale at 2.6h, while a freshly-rejoined idle agent reads healthy —
|
|
316
|
+
// a false-stall generator aimed at exactly the agents that must not be
|
|
317
|
+
// false-stalled while nobody is watching.
|
|
318
|
+
//
|
|
319
|
+
// The HIT above is kept: for an agent with no transport at all, "has not
|
|
320
|
+
// rejoined and has no live marker" is still the death signal Task 3.5
|
|
321
|
+
// specifies. What is removed is the coverage credit for freshness, which
|
|
322
|
+
// leaves coverage resting on the vcs predicate — the one that measures
|
|
323
|
+
// something the agent DID.
|
|
239
324
|
}
|
|
240
325
|
// A FRESH HEARTBEAT IS NOT PROGRESS. An agent can be alive and stuck, which is
|
|
241
326
|
// the case "notice the room" never catches: the pane is responsive, so nobody
|
|
@@ -286,6 +371,7 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
286
371
|
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
287
372
|
}).trim();
|
|
288
373
|
const age = now - Date.parse(iso);
|
|
374
|
+
measured.add(agentId);
|
|
289
375
|
if (age > limit) {
|
|
290
376
|
hits.push({ kind: "no-vcs-activity", agentId, branch: raw, minutes: Math.round(age / 60000) });
|
|
291
377
|
}
|
|
@@ -294,8 +380,15 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
294
380
|
}
|
|
295
381
|
}
|
|
296
382
|
|
|
297
|
-
|
|
298
|
-
|
|
383
|
+
// COVERAGE, not "did it run". A row is COVERED when at least one predicate
|
|
384
|
+
// produced a verdict about it; a row where every predicate came back
|
|
385
|
+
// unmeasurable was looked at and not measured, and counting it as checked is
|
|
386
|
+
// how "the clock ran" gets mistaken for "the fleet is observed".
|
|
387
|
+
const owners = inFlight.map((r) => r.owner.replace(/[`*]/g, "").trim()).filter(Boolean);
|
|
388
|
+
const blind = [...new Set(owners)].filter((id) => !measured.has(id));
|
|
389
|
+
const measurable = Math.max(0, inFlight.length - blind.length);
|
|
390
|
+
const result = { hits, checked: inFlight.length, unmeasurable, measurable, blind };
|
|
391
|
+
markRun({ hits, checked: inFlight.length, measurable });
|
|
299
392
|
return {
|
|
300
393
|
ok: true as const,
|
|
301
394
|
...result,
|
package/src/tools/transport.ts
CHANGED
|
@@ -124,7 +124,20 @@ export async function pingTool(args: { from: string; to: string; echo?: boolean
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
const reachable = transportLive && paneAlive !== false;
|
|
127
|
-
|
|
127
|
+
// heartbeatFresh is only valid EVIDENCE where something writes a heartbeat.
|
|
128
|
+
// A local tmux-push transport's liveness is its pid (isPidAlive) —
|
|
129
|
+
// hooks/tmux-pusher.mjs never calls `heartbeat`, so the field measures time
|
|
130
|
+
// since JOIN, not activity (Task 13.3/13.4, stall.ts's own established
|
|
131
|
+
// rule for exactly this transport type). Crediting it here for a
|
|
132
|
+
// tmux-push agent produced the incident this fix exists for: `alive` and
|
|
133
|
+
// `reachable` read fully healthy, `heartbeatFresh` sat in `checks` reading
|
|
134
|
+
// false, and the top-line boolean never surfaced the disagreement — a
|
|
135
|
+
// status layer discarding what its own lower layer already reported, the
|
|
136
|
+
// same shape as `stall_clock_status` before Task 13.1. A REMOTE pusher, or
|
|
137
|
+
// an agent with no probeable marker at all, has no pid to fall back on —
|
|
138
|
+
// there heartbeat genuinely IS the liveness mechanism, unchanged.
|
|
139
|
+
const heartbeatIsValidSignal = !marker || marker.transport !== "tmux-push";
|
|
140
|
+
const alive = reachable || (heartbeatIsValidSignal && heartbeatFresh);
|
|
128
141
|
|
|
129
142
|
let echoSent = false;
|
|
130
143
|
if (args.echo && alive) {
|
|
@@ -146,6 +159,11 @@ export async function pingTool(args: { from: string; to: string; echo?: boolean
|
|
|
146
159
|
registered: true,
|
|
147
160
|
heartbeatFresh,
|
|
148
161
|
heartbeatAgeSec,
|
|
162
|
+
// Whether heartbeatFresh above is real evidence for this transport, or
|
|
163
|
+
// just time-since-join. A reader who sees `heartbeatFresh: false` next
|
|
164
|
+
// to `heartbeatValid: false` should not read that as a dissenting
|
|
165
|
+
// signal — there is no signal there to dissent.
|
|
166
|
+
heartbeatValid: heartbeatIsValidSignal,
|
|
149
167
|
transport: marker?.transport ?? null,
|
|
150
168
|
transportLive,
|
|
151
169
|
...(paneAlive !== undefined ? { paneAlive, tmuxTarget: marker?.tmuxTarget } : {}),
|
package/src/tools/work.ts
CHANGED
|
@@ -17,10 +17,11 @@ import {
|
|
|
17
17
|
workDocLegacyWriteIssues,
|
|
18
18
|
LANES_V0_WRITE_ISSUE,
|
|
19
19
|
} from "../work.js";
|
|
20
|
-
import { parseFactsDoc, type FactEntry } from "@davidbalzan/groundwork-seam";
|
|
20
|
+
import { parseFactsDoc, type FactEntry, type Priority } from "@davidbalzan/groundwork-seam";
|
|
21
21
|
import { loadScopes, ownsDocument } from "./scopes.js";
|
|
22
22
|
import { AGENTS_FILE } from "../store.js";
|
|
23
23
|
import type { AgentRegistry } from "./shared.js";
|
|
24
|
+
import { summarize, blockedBy } from "./records.js";
|
|
24
25
|
|
|
25
26
|
// ---------- work state as data (Phase 8 Task 5) ----------
|
|
26
27
|
//
|
|
@@ -189,8 +190,42 @@ export const listWorkSchema = {
|
|
|
189
190
|
// native (default): discriminated v1 | lanes-v0. lanes: lossy projection of v1.
|
|
190
191
|
view: z.enum(["native", "lanes"]).optional(),
|
|
191
192
|
repo: z.string().optional(),
|
|
193
|
+
// Fetch ONE full record by id — the second call Task 15.1 asks for.
|
|
194
|
+
// Identity rows (below) never carry the body; this is how a caller gets it,
|
|
195
|
+
// and only for the one row it actually needs rather than every row's.
|
|
196
|
+
id: z.string().optional(),
|
|
192
197
|
};
|
|
193
198
|
|
|
199
|
+
// A queue row without its body — id, priority, a bounded headline, and
|
|
200
|
+
// blocked-by, the fields the doc names. `owner` is NOT included: QueueItem
|
|
201
|
+
// carries no structured owner field, and the free-text `origin:` phrase some
|
|
202
|
+
// items end with ("origin: coordinator retracting its own broadcast canon…")
|
|
203
|
+
// is prose, not an id — extracting one heuristically would fabricate a field
|
|
204
|
+
// this data model does not have. Omitted rather than guessed, same
|
|
205
|
+
// discipline as `list_agents`' heartbeatSource (Task 13.5).
|
|
206
|
+
export type QueueIdentityRow = {
|
|
207
|
+
id: string;
|
|
208
|
+
priority: Priority | null;
|
|
209
|
+
headline: string;
|
|
210
|
+
truncated: boolean;
|
|
211
|
+
blockedBy: string | null;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
function identityRowOf(item: QueueItem): QueueIdentityRow {
|
|
215
|
+
const flat = String(item.text).replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
|
|
216
|
+
return {
|
|
217
|
+
id: item.id,
|
|
218
|
+
priority: item.priority,
|
|
219
|
+
headline: summarize(item.text),
|
|
220
|
+
// `summarize` itself decides where to cut; re-deriving "did it cut" here
|
|
221
|
+
// from a second pass over the same text would be the two-matchers-one-
|
|
222
|
+
// question shape this phase keeps finding elsewhere (refsIn, COMMIT_REF).
|
|
223
|
+
// Comparing against its own bound is exact and free.
|
|
224
|
+
truncated: flat.length > 96,
|
|
225
|
+
blockedBy: blockedBy(item),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
194
229
|
export async function listWorkTool(args: {
|
|
195
230
|
project: string;
|
|
196
231
|
kind?: "queue" | "done" | "board" | "facts";
|
|
@@ -198,6 +233,7 @@ export async function listWorkTool(args: {
|
|
|
198
233
|
includeDone?: boolean;
|
|
199
234
|
view?: "native" | "lanes";
|
|
200
235
|
repo?: string;
|
|
236
|
+
id?: string;
|
|
201
237
|
}) {
|
|
202
238
|
// No import yet (or the store was deleted) → read the documents directly.
|
|
203
239
|
// This is the fallback that makes "the markdown stands alone" true in code
|
|
@@ -231,6 +267,17 @@ export async function listWorkTool(args: {
|
|
|
231
267
|
}
|
|
232
268
|
if (args.view === "lanes") board = board.map(projectV1ToLanes);
|
|
233
269
|
|
|
270
|
+
// THE SECOND CALL — one row, whole. Searches queue then done (a done entry
|
|
271
|
+
// shares the id space; an id is unique within a project's work state), so
|
|
272
|
+
// the caller does not have to know which kind its own id belongs to.
|
|
273
|
+
if (args.id !== undefined) {
|
|
274
|
+
const queueHit = queue.find((q) => q.id === args.id);
|
|
275
|
+
if (queueHit) return { ok: true as const, project: state.project, repo: state.repo, source, kind: "queue" as const, item: queueHit };
|
|
276
|
+
const doneHit = done.find((d) => d.id === args.id);
|
|
277
|
+
if (doneHit) return { ok: true as const, project: state.project, repo: state.repo, source, kind: "done" as const, item: doneHit };
|
|
278
|
+
return { ok: false as const, error: `no queue item or DONE entry with id '${args.id}' in project '${state.project}'` };
|
|
279
|
+
}
|
|
280
|
+
|
|
234
281
|
const openQueue = args.includeDone ? queue : queue.filter((q) => !q.done);
|
|
235
282
|
const filtered = args.priority ? openQueue.filter((q) => q.priority === args.priority) : openQueue;
|
|
236
283
|
|
|
@@ -239,7 +286,12 @@ export async function listWorkTool(args: {
|
|
|
239
286
|
project: state.project,
|
|
240
287
|
repo: state.repo,
|
|
241
288
|
source,
|
|
242
|
-
|
|
289
|
+
// IDENTITY ONLY (Task 15.1) — id, priority, a bounded headline, and
|
|
290
|
+
// blocked-by; never the full item text. Call again with `id` for one
|
|
291
|
+
// row's body. This is the change that makes the tool cheap: the same
|
|
292
|
+
// question that used to return every open item's ~1,500-char body now
|
|
293
|
+
// returns a fixed-width row per item.
|
|
294
|
+
...(args.kind === "queue" || args.kind === undefined ? { queue: filtered.map(identityRowOf) } : {}),
|
|
243
295
|
...(args.kind === "done" || args.kind === undefined ? { done } : {}),
|
|
244
296
|
...(args.kind === "board" || args.kind === undefined ? { board } : {}),
|
|
245
297
|
...(args.kind === "facts" || args.kind === undefined ? { facts } : {}),
|
package/src/tools/worktrees.ts
CHANGED
|
@@ -268,7 +268,51 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
|
|
|
268
268
|
for (const w of listWorktrees(repo)) {
|
|
269
269
|
const at = w.path;
|
|
270
270
|
if (samePath(at, primaryOf(repo) ?? "")) {
|
|
271
|
-
|
|
271
|
+
// THE PRIMARY CHECKOUT IS REPORTED, ALWAYS — it used to be skipped with
|
|
272
|
+
// "not a slice tree", which is true and useless. It is the tree David
|
|
273
|
+
// reads, the one every hand-run measurement runs in, and a silent skip
|
|
274
|
+
// let it sit arbitrarily far behind while this verb reported a clean
|
|
275
|
+
// sweep. "Not ours to fast-forward" and "nothing to say about it" are
|
|
276
|
+
// different claims and only the first one was intended.
|
|
277
|
+
let behind = "unknown";
|
|
278
|
+
let ahead = "unknown";
|
|
279
|
+
try {
|
|
280
|
+
behind = git(at, ["rev-list", "--count", `HEAD..${tip}`]);
|
|
281
|
+
ahead = git(at, ["rev-list", "--count", `${tip}..HEAD`]);
|
|
282
|
+
} catch {
|
|
283
|
+
/* left as unknown — an unreadable count must not read as zero */
|
|
284
|
+
}
|
|
285
|
+
const dirty = isDirty(at);
|
|
286
|
+
// FAST-FORWARD ONLY, AND ONLY WHEN CLEAN. A ff-only merge cannot rewrite
|
|
287
|
+
// history or resolve a conflict, so the worst case is a refusal. Dirty is
|
|
288
|
+
// refused for the same reason a slice tree is: uncommitted work is what a
|
|
289
|
+
// diff cannot show and git cannot give back.
|
|
290
|
+
const canFf = !dirty && ahead === "0" && behind !== "0" && behind !== "unknown";
|
|
291
|
+
if (args.apply && canFf) {
|
|
292
|
+
try {
|
|
293
|
+
git(at, ["merge", "--ff-only", ref]);
|
|
294
|
+
results.push({ path: w.path, action: "fast-forwarded", why: `primary checkout advanced ${behind} commit(s) to ${ref}` });
|
|
295
|
+
continue;
|
|
296
|
+
} catch (e) {
|
|
297
|
+
results.push({ path: w.path, action: "refused", why: `primary checkout: ff-only merge failed — ${(e as Error).message}` });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
results.push({
|
|
302
|
+
path: w.path,
|
|
303
|
+
action: behind === "0" ? "current" : "stale",
|
|
304
|
+
why:
|
|
305
|
+
behind === "0"
|
|
306
|
+
? `primary checkout is at ${ref}`
|
|
307
|
+
: `primary checkout is ${behind} commit(s) behind ${ref}` +
|
|
308
|
+
(dirty
|
|
309
|
+
? " and DIRTY — not fast-forwarded; uncommitted work is what a diff cannot show"
|
|
310
|
+
: ahead !== "0"
|
|
311
|
+
? ` and ${ahead} ahead — not fast-forwarded; it has commits ${ref} does not`
|
|
312
|
+
: args.apply
|
|
313
|
+
? ""
|
|
314
|
+
: " — pass apply:true to fast-forward it"),
|
|
315
|
+
});
|
|
272
316
|
continue;
|
|
273
317
|
}
|
|
274
318
|
if (isDirty(at)) {
|