@rse/ase 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dst/ase-agent-definition-core.js +111 -87
  2. package/dst/ase-agent-fleet.js +1 -1
  3. package/dst/ase-agent-runtime-claim.js +5 -3
  4. package/dst/ase-agent-runtime-engine.js +1 -1
  5. package/dst/ase-agent-runtime-phase.js +1 -1
  6. package/dst/ase-agent-runtime-request.js +1 -1
  7. package/dst/ase-agent-runtime.js +3 -5
  8. package/dst/ase-artifact.js +3 -3
  9. package/dst/ase-config-core.js +2 -16
  10. package/dst/ase-config-schema.js +1 -3
  11. package/dst/ase-config.js +4 -1
  12. package/dst/ase-hook.js +14 -19
  13. package/dst/ase-lib-daemon-cli.js +117 -4
  14. package/dst/ase-lib-server-cli.js +10 -10
  15. package/dst/ase-mcp.js +4 -4
  16. package/dst/ase-service-getopt.js +2 -2
  17. package/dst/ase-service-otel.js +4 -4
  18. package/dst/ase-service-skills.js +5 -15
  19. package/dst/ase-service.js +87 -210
  20. package/dst/ase-setup-common.js +1 -2
  21. package/dst/ase-setup-settings.js +6 -9
  22. package/dst/ase-setup.js +2 -3
  23. package/dst/ase-statusline.js +170 -170
  24. package/dst/ase-task-board-core.js +1 -1
  25. package/dst/ase-task-board-flow.js +7 -6
  26. package/dst/ase-task-board-graph.js +5 -4
  27. package/dst/ase-task-board-tui-control.js +51 -45
  28. package/dst/ase-task-board-tui-model.js +195 -181
  29. package/dst/ase-task-board-tui-popup.js +2 -3
  30. package/dst/ase-task-board-tui-view.js +88 -79
  31. package/dst/ase-task-board-web-client.css +1 -1
  32. package/dst/ase-task-board-web-client.js +2 -2
  33. package/dst/ase-task-board-web-server.js +4 -27
  34. package/dst/ase-task-board.js +1 -1
  35. package/dst/ase-task-format.js +1 -1
  36. package/dst/ase-task-store-client.js +17 -5
  37. package/dst/ase-task-store-core.js +5 -7
  38. package/dst/ase-task-store-issues.js +91 -0
  39. package/dst/ase-task-store-plugin-ase.js +7 -3
  40. package/dst/ase-task-store-plugin-gitea.js +11 -97
  41. package/dst/ase-task-store-plugin-github.js +5 -77
  42. package/dst/ase-task-store-plugin-gitlab.js +4 -83
  43. package/dst/ase-task.js +51 -23
  44. package/dst/ase-util-compat.js +1 -1
  45. package/dst/ase-util-diagram.js +16 -42
  46. package/dst/ase.js +5 -5
  47. package/dst/man/ase-agent-adhoc.1 +1 -1
  48. package/dst/man/ase-agent-condenser.1 +1 -1
  49. package/dst/man/ase-agent-editor.1 +1 -1
  50. package/dst/man/ase-agent-estimator.1 +1 -1
  51. package/dst/man/ase-agent-fleet.1 +1 -1
  52. package/dst/man/ase-agent-implementer.1 +1 -1
  53. package/dst/man/ase-agent-integrator.1 +1 -1
  54. package/dst/man/ase-agent-qualifier.1 +1 -1
  55. package/dst/man/ase-agent-rebooter.1 +1 -1
  56. package/dst/man/ase-agent-relay.1 +1 -1
  57. package/dst/man/ase-agent-researcher.1 +1 -1
  58. package/dst/man/ase-agent.1 +1 -1
  59. package/dst/man/ase-artifact-list.1 +1 -1
  60. package/dst/man/ase-artifact-name.1 +1 -1
  61. package/dst/man/ase-artifact.1 +1 -1
  62. package/dst/man/ase-config-delete.1 +1 -1
  63. package/dst/man/ase-config-edit.1 +1 -1
  64. package/dst/man/ase-config-get.1 +1 -1
  65. package/dst/man/ase-config-init.1 +1 -1
  66. package/dst/man/ase-config-list.1 +1 -1
  67. package/dst/man/ase-config-set.1 +1 -1
  68. package/dst/man/ase-config.1 +1 -1
  69. package/dst/man/ase-hook-permission-request.1 +1 -1
  70. package/dst/man/ase-hook-pre-tool-use.1 +1 -1
  71. package/dst/man/ase-hook-session-end.1 +1 -1
  72. package/dst/man/ase-hook-session-persona.1 +1 -1
  73. package/dst/man/ase-hook-session-start.1 +1 -1
  74. package/dst/man/ase-hook-stop.1 +1 -1
  75. package/dst/man/ase-hook-user-prompt-submit.1 +1 -1
  76. package/dst/man/ase-hook.1 +1 -1
  77. package/dst/man/ase-man.1 +1 -1
  78. package/dst/man/ase-mcp.1 +1 -1
  79. package/dst/man/ase-service-log.1 +1 -1
  80. package/dst/man/ase-service-send.1 +1 -1
  81. package/dst/man/ase-service-start.1 +1 -1
  82. package/dst/man/ase-service-status.1 +1 -1
  83. package/dst/man/ase-service-stop.1 +1 -1
  84. package/dst/man/ase-service.1 +1 -1
  85. package/dst/man/ase-setup-disable.1 +1 -1
  86. package/dst/man/ase-setup-enable.1 +1 -1
  87. package/dst/man/ase-setup-install.1 +1 -1
  88. package/dst/man/ase-setup-mcp.1 +1 -1
  89. package/dst/man/ase-setup-status.1 +1 -1
  90. package/dst/man/ase-setup-statusline.1 +1 -1
  91. package/dst/man/ase-setup-telemetry.1 +1 -1
  92. package/dst/man/ase-setup-uninstall.1 +1 -1
  93. package/dst/man/ase-setup-update.1 +1 -1
  94. package/dst/man/ase-setup.1 +1 -1
  95. package/dst/man/ase-spec-export.1 +1 -1
  96. package/dst/man/ase-spec-lint.1 +1 -1
  97. package/dst/man/ase-spec-preview.1 +1 -1
  98. package/dst/man/ase-spec.1 +1 -1
  99. package/dst/man/ase-statusline.1 +1 -1
  100. package/dst/man/ase-task-assign.1 +1 -1
  101. package/dst/man/ase-task-board.1 +3 -3
  102. package/dst/man/ase-task-delete.1 +1 -1
  103. package/dst/man/ase-task-edit.1 +1 -1
  104. package/dst/man/ase-task-idscheme.1 +1 -1
  105. package/dst/man/ase-task-lifecycle.1 +1 -1
  106. package/dst/man/ase-task-list.1 +1 -1
  107. package/dst/man/ase-task-load.1 +1 -1
  108. package/dst/man/ase-task-newid.1 +1 -1
  109. package/dst/man/ase-task-purge.1 +1 -1
  110. package/dst/man/ase-task-rename.1 +1 -1
  111. package/dst/man/ase-task-request.1 +1 -1
  112. package/dst/man/ase-task-save.1 +1 -1
  113. package/dst/man/ase-task-status.1 +1 -1
  114. package/dst/man/ase-task-store.1 +1 -1
  115. package/dst/man/ase-task-view.1 +1 -1
  116. package/dst/man/ase-task.1 +1 -1
  117. package/dst/man/ase-util-compat.1 +1 -1
  118. package/dst/man/ase-util-diagram.1 +1 -1
  119. package/dst/man/ase-util-meta.1 +1 -1
  120. package/dst/man/ase-util-metric.1 +1 -1
  121. package/dst/man/ase-util-mint.1 +1 -1
  122. package/dst/man/ase-util-worktree.1 +1 -1
  123. package/dst/man/ase-util.1 +1 -1
  124. package/dst/man/ase.1 +1 -1
  125. package/package.json +1 -1
  126. package/plugin/.claude-plugin/plugin.json +1 -1
  127. package/plugin/.codex-plugin/plugin.json +1 -1
  128. package/plugin/.github/plugin/plugin.json +1 -1
  129. package/plugin/meta/ase-common-resolve.md +2 -2
  130. package/plugin/meta/ase-skill.md +7 -0
  131. package/plugin/mods/ase-mods.ts +16 -12
  132. package/plugin/skills/ase-arch-analyze/SKILL.md +3 -1
  133. package/plugin/skills/ase-code-analyze/SKILL.md +26 -10
  134. package/plugin/skills/ase-code-analyze/help.md +11 -6
  135. package/plugin/skills/ase-code-edit/SKILL.md +5 -4
  136. package/plugin/skills/ase-code-edit/help.md +1 -1
  137. package/plugin/skills/ase-code-lint/SKILL.md +52 -27
  138. package/plugin/skills/ase-code-lint/help.md +10 -5
  139. package/plugin/skills/ase-help-intent/data.md +60 -30
  140. package/plugin/skills/ase-help-skill/help.md +3 -4
  141. package/plugin/skills/ase-meta-quorum/SKILL.md +9 -0
  142. package/plugin/skills/ase-meta-quorum/help.md +5 -3
  143. package/plugin/skills/ase-meta-workflow/sample.md +5 -3
  144. package/plugin/skills/ase-repo-changelog/SKILL.md +8 -9
  145. package/plugin/skills/ase-repo-changelog/help.md +2 -2
  146. package/plugin/skills/ase-repo-review/SKILL.md +5 -3
  147. package/plugin/skills/ase-repo-review/help.md +3 -1
  148. package/plugin/skills/ase-spec-edit/SKILL.md +2 -1
  149. package/plugin/skills/ase-spec-edit/help.md +2 -1
  150. package/plugin/skills/ase-sync-reconcile/help.md +4 -4
  151. package/plugin/skills/ase-task-dissect/SKILL.md +10 -6
  152. package/plugin/skills/ase-task-grill/help.md +1 -1
  153. package/plugin/skills/ase-task-implement/SKILL.md +58 -1
  154. package/plugin/skills/ase-task-implement/help.md +16 -0
  155. package/plugin/skills/ase-task-list/help.md +2 -2
@@ -5,9 +5,122 @@
5
5
  */
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
+ import net from "node:net";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import { spawn } from "node:child_process";
10
- import { Service } from "./ase-service.js";
11
+ import { Tail } from "tail";
12
+ /* the loopback host to probe the ports on, the range and the number of attempts
13
+ of the random port allocation, and the bounds of the append-only log files:
14
+ maximum tolerated size and number of tail lines surviving a trim */
15
+ const HOST = "127.0.0.1";
16
+ const PORT_MIN = 42000;
17
+ const PORT_MAX = 44000;
18
+ const PORT_TRIES = 20;
19
+ const LOG_MAX_SIZE = 1024 * 1024;
20
+ const LOG_KEEP_LINES = 2000;
21
+ /* reusable functionality: port allocation and log file handling of the background processes */
22
+ export class Daemon {
23
+ /* try binding a single candidate port to verify availability */
24
+ static tryBind(port) {
25
+ return new Promise((resolve) => {
26
+ const s = net.createServer();
27
+ s.once("error", () => {
28
+ resolve(false);
29
+ });
30
+ s.once("listening", () => {
31
+ s.close(() => resolve(true));
32
+ });
33
+ s.listen(port, HOST);
34
+ });
35
+ }
36
+ /* allocate the preferred port, if given and available,
37
+ else a fresh random port in PORT_MIN..PORT_MAX */
38
+ static async allocatePort(pref = null) {
39
+ if (pref !== null && await Daemon.tryBind(pref))
40
+ return pref;
41
+ for (let i = 0; i < PORT_TRIES; i++) {
42
+ const p = PORT_MIN + Math.floor(Math.random() * (PORT_MAX - PORT_MIN + 1));
43
+ if (await Daemon.tryBind(p))
44
+ return p;
45
+ }
46
+ throw new Error(`failed to allocate a port in ${PORT_MIN}..${PORT_MAX} after ${PORT_TRIES} attempts`);
47
+ }
48
+ /* trim the log file down to its tail if it grew beyond the size limit */
49
+ static trimLog(logFile) {
50
+ try {
51
+ if (!fs.existsSync(logFile) || fs.statSync(logFile).size <= LOG_MAX_SIZE)
52
+ return;
53
+ const tail = Daemon.readLogTail(logFile, LOG_KEEP_LINES);
54
+ fs.writeFileSync(logFile, tail.length > 0 ? `${tail}\n` : "");
55
+ }
56
+ catch {
57
+ /* intentionally ignore all trimming errors, as a
58
+ non-trimmable log file must never block the service */
59
+ }
60
+ }
61
+ /* read the last N non-empty lines of a log file for diagnostics */
62
+ static readLogTail(logFile, lines) {
63
+ let fd = null;
64
+ try {
65
+ fd = fs.openSync(logFile, "r");
66
+ const size = fs.fstatSync(fd).size;
67
+ const CHUNK = 8192;
68
+ const chunks = [];
69
+ let pos = size;
70
+ let count = 0;
71
+ while (pos > 0 && count <= lines) {
72
+ const n = Math.min(CHUNK, pos);
73
+ const buf = Buffer.alloc(n);
74
+ pos -= n;
75
+ fs.readSync(fd, buf, 0, n, pos);
76
+ chunks.unshift(buf);
77
+ for (let i = 0; i < n; i++)
78
+ if (buf[i] === 10)
79
+ count++;
80
+ }
81
+ /* decode once after re-assembly to avoid splitting
82
+ multi-byte UTF-8 sequences at chunk boundaries */
83
+ const all = Buffer.concat(chunks).toString("utf8")
84
+ .split("\n").filter((l) => l.length > 0);
85
+ return all.slice(-lines).join("\n");
86
+ }
87
+ catch {
88
+ return "";
89
+ }
90
+ finally {
91
+ if (fd !== null)
92
+ fs.closeSync(fd);
93
+ }
94
+ }
95
+ /* show a log file on stdout, either completely or its last <lines> lines only, and
96
+ optionally follow it for newly appended lines (default: the last 10 lines first),
97
+ where each line is passed through <transform> (e.g. to colorize parts of it) */
98
+ static async showLog(logFile, opts, transform = (line) => line) {
99
+ const lines = opts.lines !== undefined ? Number(opts.lines) : null;
100
+ if (lines !== null && (!Number.isInteger(lines) || lines < 1))
101
+ throw new Error(`invalid number of lines: ${opts.lines}`);
102
+ if (!fs.existsSync(logFile))
103
+ return 0;
104
+ if (opts.follow) {
105
+ /* follow until interrupted (on log trimming,
106
+ "tail" continues at the new end of the file) */
107
+ const tail = new Tail(logFile, { nLines: lines ?? 10 });
108
+ tail.on("line", (line) => {
109
+ process.stdout.write(`${transform(line)}\n`);
110
+ });
111
+ return await new Promise((_resolve, reject) => {
112
+ tail.on("error", (err) => {
113
+ reject(err instanceof Error ? err : new Error(String(err)));
114
+ });
115
+ });
116
+ }
117
+ const text = lines !== null ?
118
+ Daemon.readLogTail(logFile, lines) :
119
+ fs.readFileSync(logFile, "utf8").replace(/\n$/, "");
120
+ process.stdout.write(text.length > 0 ? `${text.split("\n").map(transform).join("\n")}\n` : "");
121
+ return 0;
122
+ }
123
+ }
11
124
  /* the CLI command group managing a long-running process (a server, an agent, or a fleet
12
125
  of them) in the background or in the foreground: it registers the command group, spawns
13
126
  the process detached into its log file, awaits its startup, checks whether it is alive,
@@ -47,14 +160,14 @@ export class DaemonCommand {
47
160
  "(not yet run in the background, as one run in the foreground logs to the terminal)");
48
161
  return;
49
162
  }
50
- process.exitCode = await Service.showLog(file, opts, transform);
163
+ process.exitCode = await Daemon.showLog(file, opts, transform);
51
164
  });
52
165
  }
53
166
  /* spawn the CLI with the arguments <args> detached, with the additional
54
167
  environment <env>, logging into the (trimmed) log file <logFile> */
55
168
  spawnDetached(args, env, logFile) {
56
169
  fs.mkdirSync(path.dirname(logFile), { recursive: true });
57
- Service.trimLog(logFile);
170
+ Daemon.trimLog(logFile);
58
171
  const fd = fs.openSync(logFile, "a");
59
172
  const entry = fileURLToPath(new URL("./ase.js", import.meta.url));
60
173
  const child = spawn(process.execPath, [entry, ...args], {
@@ -95,7 +208,7 @@ export class DaemonCommand {
95
208
  if (failure === null)
96
209
  return;
97
210
  const reason = `${this.label}: ${failure}`;
98
- const tail = Service.readLogTail(logFile, 20);
211
+ const tail = Daemon.readLogTail(logFile, 20);
99
212
  throw new Error(tail.length > 0 ? `${reason}\n---- ${logFile} (tail) ----\n${tail}` : reason);
100
213
  }
101
214
  /* check whether a process is still alive */
@@ -13,8 +13,8 @@ import { isSeq, isScalar } from "yaml";
13
13
  import { Config } from "./ase-config-core.js";
14
14
  import { parseScope } from "./ase-config-scope.js";
15
15
  import { isLogLevel } from "./ase-lib-log.js";
16
- import { Service } from "./ase-service.js";
17
- import { DaemonCommand } from "./ase-lib-daemon-cli.js";
16
+ import { writeStdout } from "./ase-lib-stdio.js";
17
+ import { Daemon, DaemonCommand } from "./ase-lib-daemon-cli.js";
18
18
  import { urlHost, isLoopback, serverHeader } from "./ase-lib-server-bind.js";
19
19
  /* the defaults */
20
20
  export const DEFAULT_ADDRESS = "127.0.0.1";
@@ -44,7 +44,7 @@ export const configuredTls = (cfg, prefix) => {
44
44
  if (isLocal(new URL(url.value).hostname.replace(/^\[|\]$/g, "")))
45
45
  return { cert: null, key: null };
46
46
  }
47
- catch (_err) {
47
+ catch {
48
48
  /* an invalid URL is reported by its users */
49
49
  }
50
50
  }
@@ -270,7 +270,7 @@ export class ServerCommand extends DaemonCommand {
270
270
  child.kill("SIGTERM");
271
271
  child.unref();
272
272
  }
273
- const tail = Service.readLogTail(logFile, 20);
273
+ const tail = Daemon.readLogTail(logFile, 20);
274
274
  const reason = exited ?
275
275
  `${this.label}: server exited during startup (code ${exitCode})` :
276
276
  probed === "foreign" ?
@@ -351,7 +351,7 @@ export class ServerCommand extends DaemonCommand {
351
351
  /* re-allocate a configured port held by a foreign service */
352
352
  if (foreign)
353
353
  this.log.write("warning", `${this.label}: configured port ${port} in use by foreign service, re-allocating`);
354
- port = await Service.allocatePort();
354
+ port = await Daemon.allocatePort();
355
355
  }
356
356
  let token = opts.token ?? process.env[this.env("TOKEN")] ?? ctx.token;
357
357
  if (token === undefined || token === null || token === "")
@@ -381,26 +381,26 @@ export class ServerCommand extends DaemonCommand {
381
381
  async doStatus() {
382
382
  const ctx = this.loadContext();
383
383
  if (ctx.port === null) {
384
- process.stdout.write(`${this.label}: not running (no port configured)\n`);
384
+ await writeStdout(`${this.label}: not running (no port configured)\n`);
385
385
  return 1;
386
386
  }
387
387
  const address = ctx.address ?? DEFAULT_ADDRESS;
388
388
  const scheme = ctx.tlsCert !== null ? "https" : "http";
389
389
  const state = await this.probe(address, ctx.port, ctx.token, ctx.tlsCert !== null);
390
390
  if (state === "running") {
391
- process.stdout.write(`${this.label}: running on ${scheme}://${urlHost(address)}:${ctx.port}` +
391
+ await writeStdout(`${this.label}: running on ${scheme}://${urlHost(address)}:${ctx.port}` +
392
392
  (ctx.pid !== null ? ` (pid ${ctx.pid})` : "") + this.running(ctx) + "\n");
393
393
  return 0;
394
394
  }
395
395
  if (state === "unauthorized") {
396
- process.stdout.write(`${this.label}: running on ${scheme}://${urlHost(address)}:${ctx.port}, but the configured token is rejected\n`);
396
+ await writeStdout(`${this.label}: running on ${scheme}://${urlHost(address)}:${ctx.port}, but the configured token is rejected\n`);
397
397
  return 1;
398
398
  }
399
399
  if (state === "foreign") {
400
- process.stdout.write(`${this.label}: not running (port ${ctx.port} in use by foreign service)\n`);
400
+ await writeStdout(`${this.label}: not running (port ${ctx.port} in use by foreign service)\n`);
401
401
  return 1;
402
402
  }
403
- process.stdout.write(`${this.label}: not running (port ${ctx.port} not responding)\n`);
403
+ await writeStdout(`${this.label}: not running (port ${ctx.port} not responding)\n`);
404
404
  return 1;
405
405
  }
406
406
  /* stop flow: terminate the server process and wait until it is gone */
package/dst/ase-mcp.js CHANGED
@@ -72,7 +72,7 @@ export default class MCPCommand {
72
72
  const value = state.getExplicit("basedir");
73
73
  return typeof value === "string" && value !== "" ? value : null;
74
74
  }
75
- catch (_e) {
75
+ catch {
76
76
  /* best-effort: an unreadable session config just defers the lookup */
77
77
  return null;
78
78
  }
@@ -139,7 +139,7 @@ export default class MCPCommand {
139
139
  next.onerror = (err) => {
140
140
  this.log.write("error", `mcp: http: ${err.message}`);
141
141
  };
142
- /* service closed the connection — try to recover */
142
+ /* service closed the connection -- try to recover */
143
143
  next.onclose = () => {
144
144
  if (client !== next || closedByUs.has(next) || bridgeDone || reconnecting)
145
145
  return;
@@ -183,7 +183,7 @@ export default class MCPCommand {
183
183
  if (reconnecting)
184
184
  return;
185
185
  reconnecting = true;
186
- this.log.write("warning", `mcp: ${reason} — reconnecting`);
186
+ this.log.write("warning", `mcp: ${reason} -- reconnecting`);
187
187
  reconnect(0).catch(() => { });
188
188
  };
189
189
  /* bounded buffer for messages arriving while no HTTP client is connected */
@@ -315,7 +315,7 @@ export default class MCPCommand {
315
315
  await connectClient();
316
316
  }
317
317
  catch (err) {
318
- /* service vanished between probe and connect — recover instead of crashing */
318
+ /* service vanished between probe and connect -- recover instead of crashing */
319
319
  triggerReconnect(`initial connect failed: ${this.asError(err).message}`);
320
320
  }
321
321
  }
@@ -23,7 +23,7 @@ const tokenizeRanges = (input) => {
23
23
  const quote = ch;
24
24
  i++;
25
25
  while (i < input.length && input[i] !== quote) {
26
- if (input[i] === "\\" && i + 1 < input.length)
26
+ if (quote === "\"" && input[i] === "\\" && i + 1 < input.length)
27
27
  i++;
28
28
  i++;
29
29
  }
@@ -50,7 +50,7 @@ const unquote = (s) => {
50
50
  const quote = ch;
51
51
  j++;
52
52
  while (j < s.length && s[j] !== quote) {
53
- if (s[j] === "\\" && j + 1 < s.length) {
53
+ if (quote === "\"" && s[j] === "\\" && j + 1 < s.length) {
54
54
  out += s[j + 1];
55
55
  j += 2;
56
56
  }
@@ -580,7 +580,7 @@ export class UsageTracker {
580
580
  const self = userId();
581
581
  if (assignee.trim() === "")
582
582
  throw new Error("usage: empty assignee (use \"none\" to unassign)");
583
- const to = assignee === "me" ? self : assignee.trim();
583
+ const to = assignee.trim() === "me" ? self : assignee.trim();
584
584
  /* (keep the session out of the adoptions meanwhile, and let an assignment to oneself
585
585
  settle the adoption of its active task, as the explicit assignment takes precedence) */
586
586
  this.explicit.add(session);
@@ -591,6 +591,7 @@ export class UsageTracker {
591
591
  this.explicit.delete(session);
592
592
  }
593
593
  }
594
+ /* perform the assignment of "assign" (with the session kept out of the adoptions) */
594
595
  async assignSession(id, to, self, session) {
595
596
  const warning = await Task.checkAssignee(this.log, to);
596
597
  const from = await Task.assign(this.log, id, to === "none" ? null : to);
@@ -952,6 +953,7 @@ export class UsageTracker {
952
953
  this.unwatch = null;
953
954
  }
954
955
  }
956
+ /* close resp. implicitly account the open windows of the watched tasks whose assignee changed elsewhere */
955
957
  async check() {
956
958
  for (const task of Array.from(this.watched)) {
957
959
  const w = this.openWindow(task);
@@ -1212,9 +1214,7 @@ export class UsageTracker {
1212
1214
  (dropping the records of unregistered sessions), with the session registry
1213
1215
  re-read from the telemetry state file only once the file changed */
1214
1216
  route(records) {
1215
- const bySession = new Map();
1216
- for (const r of records)
1217
- bySession.set(r.session, [...(bySession.get(r.session) ?? []), r]);
1217
+ const bySession = Map.groupBy(records, (r) => r.session);
1218
1218
  let registry;
1219
1219
  try {
1220
1220
  const file = UsageTracker.stateFile();
@@ -187,7 +187,8 @@ export class Skills {
187
187
  if (m === null)
188
188
  return "N.A.";
189
189
  const base = parseFloat(m[1].replace(/,/g, ""));
190
- const mult = m[2] === "K" ? 1_000 : m[2] === "M" ? 1_000_000 : m[2] === "B" ? 1_000_000_000 : 1;
190
+ const unit = m[2].toUpperCase();
191
+ const mult = unit === "K" ? 1_000 : unit === "M" ? 1_000_000 : unit === "B" ? 1_000_000_000 : 1;
191
192
  const n = Math.round(base * mult);
192
193
  return Number.isFinite(n) ? n : "N.A.";
193
194
  }
@@ -246,20 +247,9 @@ export class Skills {
246
247
  const now = Date.now();
247
248
  const msPerDay = 1000 * 60 * 60 * 24;
248
249
  const halfLife = 365 / 2;
249
- /* lifespan requires both timestamps; recentness requires the
250
- updated timestamp -- any unavailable date-derived factor is
251
- given a neutral default so the entry can still be ranked by the
252
- remaining metrics, instead of collapsing the product to zero.
253
- For `lifespan` (an unbounded duration) the multiplicative-neutral
254
- `1` is also the low floor, so a missing value cannot unfairly
255
- inflate the rank. For `recentness`, however, the value is an
256
- exp-decay factor bounded in `(0,1]` where `1` is the *ceiling*
257
- (a just-now update), not a floor -- defaulting a missing update
258
- date to `1` would hand packages with absent metadata the highest
259
- possible recentness and let them outrank genuinely fresh ones.
260
- We therefore default a missing `recentness` to the decay value at
261
- one half-life (`0.5`), a conservative midpoint that keeps the
262
- entry rankable without rewarding the missing date. */
250
+ /* an unavailable date-derived factor gets a neutral default: a missing lifespan
251
+ its floor "1", a missing recentness (whose ceiling is "1") the decay value at
252
+ one half-life ("0.5"), so a missing date is never rewarded */
263
253
  const lifespan = (!Number.isNaN(cMs) && !Number.isNaN(uMs)) ? Math.max(1, uMs - cMs) : 1;
264
254
  const recentness = !Number.isNaN(uMs) ? 0.5 ** (Math.max(0, (now - uMs) / msPerDay) / halfLife) : 0.5;
265
255
  let rank = d * s * lifespan * recentness;