@monster-orange/den 0.4.9 → 0.4.10

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/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.4.10] - 2026-11-07
8
+
9
+ ### Changed
10
+
11
+ - **A confirmed merge stays confirmed.** Once `gh pr view` says an agent's
12
+ pull request is MERGED, its record keeps that (`prMergeConfirmed`), and
13
+ `usage` doesn't ask GitHub about it again. A later run that can't check
14
+ no longer drops it. A merged flag from before 0.4.10, with no
15
+ confirmation, is checked again.
16
+ - **`usage agents` counts only real asks.** "Asked GitHub for the state of
17
+ N pull requests" counts the times gh ran. When gh is not installed, it
18
+ says "Couldn't check N merges: gh is not installed." instead.
19
+ - **The PR column** shows the confirmed pull request, with "(merged)", when
20
+ the agent merged one it didn't open.
21
+ - **A bare `gh pr merge`** (the branch's own pull request) is confirmed by
22
+ the number gh gives: the check is now `gh pr view --json number,state`.
23
+ With no number, it is not marked; an empty ref is never a key.
24
+ - **Dropping a mark drops its rotation.** When gh no longer confirms a
25
+ merged flag, a "PR merged" rotation it made due is cleared too. A
26
+ rotation due after compactions is left alone.
27
+
28
+ ### Migrations
29
+
30
+ None.
31
+
7
32
  ## [0.4.9] - 2026-11-07
8
33
 
9
34
  A session ends when its pull request merges, or after 3 compactions. The
package/README.md CHANGED
@@ -10,8 +10,8 @@
10
10
  </p>
11
11
 
12
12
  <picture>
13
- <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.9/hero-dark.png">
14
- <img src="docs/images/0.4.9/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
13
+ <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.10/hero-dark.png">
14
+ <img src="docs/images/0.4.10/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
15
15
  </picture>
16
16
 
17
17
  ## Set up by your agent
@@ -38,7 +38,7 @@ It doesn't replace your tools. Your repos, Docker, your test runners, your secre
38
38
  yours. Den connects them and turns on good practice from the start.
39
39
 
40
40
  <p align="center">
41
- <img src="docs/images/0.4.9/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
41
+ <img src="docs/images/0.4.10/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
42
42
  </p>
43
43
 
44
44
  ## How it helps
@@ -68,10 +68,10 @@ yours. Den connects them and turns on good practice from the start.
68
68
  lives in your own repo.
69
69
 
70
70
  <p align="center">
71
- <img src="docs/images/0.4.9/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
72
- <img src="docs/images/0.4.9/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
73
- <img src="docs/images/0.4.9/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
74
- <img src="docs/images/0.4.9/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
71
+ <img src="docs/images/0.4.10/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
72
+ <img src="docs/images/0.4.10/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
73
+ <img src="docs/images/0.4.10/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
74
+ <img src="docs/images/0.4.10/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
75
75
  </p>
76
76
 
77
77
  ## Get started
@@ -2482,7 +2482,7 @@ report. It works on den-claude for personas with Den's hooks on.
2482
2482
  A merge is never taken from text. The agent must have run `gh pr merge`
2483
2483
  as a command (after any `cd` or `VAR=value` prefix; not `--help`, not
2484
2484
  `echo gh pr merge`), and it must have exited 0. Then `usage` asks
2485
- `gh pr view <ref> --json state` in the agent's folder, and only
2485
+ `gh pr view <ref> --json number,state` in the agent's folder, and only
2486
2486
  `MERGED` counts. With no gh, or on any error, it is not marked. That
2487
2487
  query is the one thing `usage` asks outside this machine; it says so
2488
2488
  when it does.
@@ -2762,9 +2762,13 @@ and its wall-clock time. It matches transcripts to agents by the
2762
2762
  conversation id Den saved at start. It prints the share of agents with
2763
2763
  both. Transcripts never leave the machine, and their text is never kept:
2764
2764
  only counts. "Whether it merged" is checked: when an agent ran
2765
- `gh pr merge`, `usage` asks `gh pr view <ref> --json state` to confirm it.
2765
+ `gh pr merge`, `usage` asks `gh pr view <ref> --json number,state` to confirm it.
2766
2766
  That query, the pull request's number or URL, is the only thing it asks
2767
- outside, and the output says when it asked.
2767
+ outside, and the output says when it asked (or that it couldn't, because
2768
+ gh is not installed). A merge gh confirmed is kept in the record
2769
+ (`prMergeConfirmed`, the pull request's ref or, for a bare `gh pr merge`,
2770
+ the number gh gave) and not asked about again. A mark gh no longer
2771
+ confirms is dropped, with the "PR merged" rotation it made due.
2768
2772
 
2769
2773
  `<cli> usage report [--days 7] [--json]` writes a weekly summary to
2770
2774
  `<state dir>/usage/<date>.json` and `.md`, compared with the last one:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -80,6 +80,11 @@ export interface AgentOutcome {
80
80
  prOpened?: string;
81
81
  /** That pull request merged in the run. */
82
82
  prMerged?: boolean;
83
+ /**
84
+ * The pull request gh said is MERGED (0.4.10): a merge confirmed once
85
+ * stays, and is not asked again.
86
+ */
87
+ prMergeConfirmed?: string;
83
88
  /** The last test run it made passed (true) or failed (false). */
84
89
  testsGreen?: boolean;
85
90
  /** From its start to its last activity, in milliseconds. */
@@ -235,6 +235,15 @@ export function markDue(
235
235
  return true;
236
236
  }
237
237
 
238
+ /** Clear a rotation due for `reason` (a merge mark gh no longer confirms). */
239
+ export function clearDue(filesDir: string, reason: RotateReason): boolean {
240
+ const s = readRotation(filesDir);
241
+ if (!s || s.due !== reason) return false;
242
+ const { due: _d, dueAt: _a, ...rest } = s;
243
+ writeRotation(filesDir, rest);
244
+ return true;
245
+ }
246
+
238
247
  /** Whether the state says a rotation is due, for this conversation. */
239
248
  export function dueFor(
240
249
  s: RotationState | null,
@@ -6,27 +6,39 @@
6
6
  import { execFile } from "child_process";
7
7
 
8
8
  /**
9
- * `gh pr view [<ref>] [-R <repo>] --json state -q .state`;
10
- * rejects on any error. Only the ref and repo go out, to GitHub, by gh.
9
+ * `gh pr view [<ref>] [-R <repo>] --json number,state`: the state and the
10
+ * number (a bare ref is the branch's PR, and the number names it). Rejects
11
+ * on any error, or an answer that isn't that JSON. Only the ref and repo
12
+ * go out, to GitHub, by gh.
11
13
  */
12
14
  export function ghPrState(env: NodeJS.ProcessEnv) {
13
15
  return (ref: string, o: { repo?: string; cwd: string }) =>
14
- new Promise<string | undefined>((res, rej) => {
16
+ new Promise<{ state: string; number?: number }>((res, rej) => {
15
17
  const args = [
16
18
  "pr",
17
19
  "view",
18
20
  ...(ref ? [ref] : []),
19
21
  ...(o.repo ? ["-R", o.repo] : []),
20
22
  "--json",
21
- "state",
22
- "-q",
23
- ".state",
23
+ "number,state",
24
24
  ];
25
25
  execFile(
26
26
  "gh",
27
27
  args,
28
28
  { cwd: o.cwd, env, timeout: 15_000, maxBuffer: 64 * 1024 },
29
- (e, stdout) => (e ? rej(e) : res(String(stdout).trim())),
29
+ (e, stdout) => {
30
+ if (e) return rej(e);
31
+ try {
32
+ const o = JSON.parse(String(stdout)) as Record<string, unknown>;
33
+ if (typeof o.state !== "string") throw new Error("no state");
34
+ res({
35
+ state: o.state,
36
+ ...(typeof o.number === "number" ? { number: o.number } : {}),
37
+ });
38
+ } catch (err) {
39
+ rej(err);
40
+ }
41
+ },
30
42
  );
31
43
  });
32
44
  }
@@ -102,6 +102,19 @@ export function outcomeOf(
102
102
  return out;
103
103
  }
104
104
 
105
+ /**
106
+ * What a pull request check answers: its state ("MERGED"), and its number
107
+ * when gh gave one (a bare `gh pr merge` is the branch's PR: the number is
108
+ * how it is kept).
109
+ */
110
+ export type PrStateAnswer = string | { state: string; number?: number };
111
+
112
+ /** Checks one pull request's state (gh pr view), in the agent's folder. */
113
+ export type PrStateCheck = (
114
+ ref: string,
115
+ o: { repo?: string; cwd: string },
116
+ ) => Promise<PrStateAnswer | undefined>;
117
+
105
118
  /**
106
119
  * Whether one of the transcripts' `gh pr merge` claims is a pull request
107
120
  * gh says is MERGED. A bare `gh pr merge` (the branch's PR) is checked by
@@ -111,12 +124,9 @@ async function mergeVerified(
111
124
  rec: AgentRecord,
112
125
  stats: readonly TranscriptStats[],
113
126
  outcome: AgentOutcome,
114
- prState?: (
115
- ref: string,
116
- o: { repo?: string; cwd: string },
117
- ) => Promise<string | undefined>,
118
- ): Promise<boolean> {
119
- if (!prState) return false;
127
+ prState?: PrStateCheck,
128
+ ): Promise<string | undefined> {
129
+ if (!prState) return undefined;
120
130
  const seen = new Set<string>();
121
131
  for (const c of stats.flatMap((s) => s.prMerges)) {
122
132
  const ref = c.ref || outcome.prOpened || "";
@@ -128,12 +138,17 @@ async function mergeVerified(
128
138
  ...(c.repo ? { repo: c.repo } : {}),
129
139
  cwd: rec.cwd,
130
140
  });
131
- if (st?.trim().toUpperCase() === "MERGED") return true;
141
+ const state = typeof st === "string" ? st : st?.state;
142
+ if (state?.trim().toUpperCase() !== "MERGED") continue;
143
+ // Kept by its ref, or by the number gh gave; never by "".
144
+ const n = typeof st === "object" ? st.number : undefined;
145
+ const key = ref || (Number.isInteger(n) && n! > 0 ? String(n) : "");
146
+ if (key) return key;
132
147
  } catch {
133
148
  // Not checked: not marked.
134
149
  }
135
150
  }
136
- return false;
151
+ return undefined;
137
152
  }
138
153
 
139
154
  /**
@@ -146,14 +161,11 @@ export async function agentUsage(
146
161
  env?: NodeJS.ProcessEnv;
147
162
  source?: (runtime: string) => UsageSource | undefined;
148
163
  /**
149
- * A pull request's state (`gh pr view <ref> --json state`), run in the
164
+ * A pull request's state (`gh pr view <ref> --json number,state`), run in the
150
165
  * agent's folder: "MERGED" marks the outcome merged. Without it, or on
151
166
  * any error, nothing is marked: a merge is never taken from text.
152
167
  */
153
- prState?: (
154
- ref: string,
155
- o: { repo?: string; cwd: string },
156
- ) => Promise<string | undefined>;
168
+ prState?: PrStateCheck;
157
169
  } = {},
158
170
  ): Promise<AgentUsageRow[]> {
159
171
  const pick = opts.source ?? sourceFor;
@@ -198,8 +210,15 @@ export async function agentUsage(
198
210
  }
199
211
  const usage = sumUsage(stats);
200
212
  const outcome = outcomeOf(rec, stats);
201
- if (await mergeVerified(rec, stats, outcome, opts.prState))
213
+ // Confirmed once, kept: GitHub is not asked again.
214
+ const confirmed =
215
+ rec.outcome?.prMerged && rec.outcome.prMergeConfirmed
216
+ ? rec.outcome.prMergeConfirmed
217
+ : await mergeVerified(rec, stats, outcome, opts.prState);
218
+ if (confirmed) {
202
219
  outcome.prMerged = true;
220
+ outcome.prMergeConfirmed = confirmed;
221
+ }
203
222
  rows.push({
204
223
  ...base,
205
224
  transcripts: files.length,
@@ -3,7 +3,7 @@
3
3
  * runtimes' own transcripts on this machine (usage sources, plugin kind
4
4
  * `usage`). None of it is sent anywhere: it reads local files and writes
5
5
  * local files. The one question it asks outside: when an agent ran
6
- * `gh pr merge`, `gh pr view <ref> --json state` confirms the merge
6
+ * `gh pr merge`, `gh pr view <ref> --json number,state` confirms the merge
7
7
  * (gh-pr-state.ts) before it counts.
8
8
  *
9
9
  * agents [-w <ws>] [--json] each agent's tokens and outcome (PR opened
@@ -22,8 +22,9 @@ import {
22
22
  readRecord,
23
23
  writeRecord,
24
24
  } from "../../shared/agents/records.js";
25
- import { markDue } from "../../shared/agents/rotation.js";
25
+ import { clearDue, markDue } from "../../shared/agents/rotation.js";
26
26
  import { ghPrState } from "../../shared/git/gh-pr-state.js";
27
+ import type { PrStateCheck } from "../../shared/usage/report.js";
27
28
  import { readAgentsConfig } from "../../shared/den-config.js";
28
29
  import { getStateDir } from "../../shared/paths.js";
29
30
  import { product } from "../../shared/product.js";
@@ -77,24 +78,31 @@ export interface UsageDeps {
77
78
  rotateAfter?: () => { afterCompactions: number } | undefined;
78
79
  /**
79
80
  * A pull request's state, to confirm a `gh pr merge` the agent ran
80
- * (default: `gh pr view <ref> --json state` in the agent's folder).
81
+ * (default: `gh pr view <ref> --json number,state` in the agent's folder).
81
82
  */
82
- prState?: (
83
- ref: string,
84
- o: { repo?: string; cwd: string },
85
- ) => Promise<string | undefined>;
83
+ prState?: PrStateCheck;
86
84
  }
87
85
 
88
- /** The check, counting how often it asked (the summary line says so). */
86
+ /**
87
+ * The check, counting how often gh ran (`asked`) and how often it could not
88
+ * run because gh is not installed (`missing`); the summary says both.
89
+ */
89
90
  function counted(
90
91
  f: NonNullable<UsageDeps["prState"]>,
91
- ): NonNullable<UsageDeps["prState"]> & { asked: number } {
92
+ ): NonNullable<UsageDeps["prState"]> & { asked: number; missing: number } {
92
93
  const g = Object.assign(
93
- (ref: string, o: { repo?: string; cwd: string }) => {
94
- g.asked++;
95
- return f(ref, o);
94
+ async (ref: string, o: { repo?: string; cwd: string }) => {
95
+ try {
96
+ const st = await f(ref, o);
97
+ g.asked++;
98
+ return st;
99
+ } catch (e) {
100
+ if ((e as NodeJS.ErrnoException)?.code === "ENOENT") g.missing++;
101
+ else g.asked++;
102
+ throw e;
103
+ }
96
104
  },
97
- { asked: 0 },
105
+ { asked: 0, missing: 0 },
98
106
  );
99
107
  return g;
100
108
  }
@@ -144,6 +152,9 @@ function save(stateDir: string, rows: readonly AgentUsageRow[]): void {
144
152
  });
145
153
  if (r.outcome?.prMerged)
146
154
  markDue(agentFilesDir(stateDir, rec.id), "pr-merged", rec.resumeToken);
155
+ // A mark gh no longer confirms is dropped, and its rotation with it.
156
+ else if (rec.outcome?.prMerged)
157
+ clearDue(agentFilesDir(stateDir, rec.id), "pr-merged");
147
158
  }
148
159
  }
149
160
 
@@ -211,7 +222,9 @@ async function agents(
211
222
  : "red",
212
223
  r.outcome?.prOpened
213
224
  ? `${r.outcome.prOpened}${r.outcome.prMerged ? " (merged)" : ""}`
214
- : "-",
225
+ : r.outcome?.prMergeConfirmed
226
+ ? `${r.outcome.prMergeConfirmed} (merged)`
227
+ : "-",
215
228
  r.compactions === undefined ? "-" : String(r.compactions),
216
229
  String(r.rotations),
217
230
  ]),
@@ -226,6 +239,10 @@ async function agents(
226
239
  d.out(
227
240
  `To confirm a merge, it asked GitHub for the state of ${d.prState.asked} pull request${d.prState.asked === 1 ? "" : "s"} (gh pr view).`,
228
241
  );
242
+ if (d.prState.missing)
243
+ d.out(
244
+ `Couldn't check ${d.prState.missing} merge${d.prState.missing === 1 ? "" : "s"}: gh is not installed.`,
245
+ );
229
246
  return 0;
230
247
  }
231
248