@nac3/forge-cli 1.19.5 → 1.20.0

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 (78) hide show
  1. package/dist/bin/yf.d.ts.map +1 -1
  2. package/dist/bin/yf.js +2 -1
  3. package/dist/bin/yf.js.map +1 -1
  4. package/dist/chat/instance_lock.d.ts +98 -0
  5. package/dist/chat/instance_lock.d.ts.map +1 -1
  6. package/dist/chat/instance_lock.js +135 -11
  7. package/dist/chat/instance_lock.js.map +1 -1
  8. package/dist/chat/instances.d.ts +84 -0
  9. package/dist/chat/instances.d.ts.map +1 -0
  10. package/dist/chat/instances.js +224 -0
  11. package/dist/chat/instances.js.map +1 -0
  12. package/dist/chat/panel.d.ts.map +1 -1
  13. package/dist/chat/panel.js +276 -5
  14. package/dist/chat/panel.js.map +1 -1
  15. package/dist/chat/server.d.ts.map +1 -1
  16. package/dist/chat/server.js +64 -1
  17. package/dist/chat/server.js.map +1 -1
  18. package/dist/chat/tools/evidence.d.ts.map +1 -1
  19. package/dist/chat/tools/evidence.js +7 -3
  20. package/dist/chat/tools/evidence.js.map +1 -1
  21. package/dist/commands/task.d.ts +13 -0
  22. package/dist/commands/task.d.ts.map +1 -1
  23. package/dist/commands/task.js +134 -0
  24. package/dist/commands/task.js.map +1 -1
  25. package/dist/commands/upgrade.d.ts +15 -0
  26. package/dist/commands/upgrade.d.ts.map +1 -1
  27. package/dist/commands/upgrade.js +63 -0
  28. package/dist/commands/upgrade.js.map +1 -1
  29. package/dist/commands/urazuke.d.ts.map +1 -1
  30. package/dist/commands/urazuke.js +68 -1
  31. package/dist/commands/urazuke.js.map +1 -1
  32. package/dist/core/forge_profile.d.ts.map +1 -1
  33. package/dist/core/forge_profile.js +41 -4
  34. package/dist/core/forge_profile.js.map +1 -1
  35. package/dist/core/whats_new.d.ts.map +1 -1
  36. package/dist/core/whats_new.js +14 -0
  37. package/dist/core/whats_new.js.map +1 -1
  38. package/dist/urazuke/current.d.ts +5 -0
  39. package/dist/urazuke/current.d.ts.map +1 -1
  40. package/dist/urazuke/current.js +14 -3
  41. package/dist/urazuke/current.js.map +1 -1
  42. package/dist/urazuke/cycle.d.ts.map +1 -1
  43. package/dist/urazuke/cycle.js +6 -2
  44. package/dist/urazuke/cycle.js.map +1 -1
  45. package/dist/urazuke/evidence.d.ts.map +1 -1
  46. package/dist/urazuke/evidence.js +9 -1
  47. package/dist/urazuke/evidence.js.map +1 -1
  48. package/dist/urazuke/freeze.d.ts +29 -0
  49. package/dist/urazuke/freeze.d.ts.map +1 -0
  50. package/dist/urazuke/freeze.js +150 -0
  51. package/dist/urazuke/freeze.js.map +1 -0
  52. package/dist/urazuke/gate.d.ts +18 -1
  53. package/dist/urazuke/gate.d.ts.map +1 -1
  54. package/dist/urazuke/gate.js +21 -0
  55. package/dist/urazuke/gate.js.map +1 -1
  56. package/dist/urazuke/gates_data.d.ts +219 -0
  57. package/dist/urazuke/gates_data.d.ts.map +1 -0
  58. package/dist/urazuke/gates_data.js +441 -0
  59. package/dist/urazuke/gates_data.js.map +1 -0
  60. package/dist/urazuke/prompts_client.d.ts +6 -0
  61. package/dist/urazuke/prompts_client.d.ts.map +1 -1
  62. package/dist/urazuke/prompts_client.js +1 -1
  63. package/dist/urazuke/prompts_client.js.map +1 -1
  64. package/dist/urazuke/receipts.d.ts +53 -1
  65. package/dist/urazuke/receipts.d.ts.map +1 -1
  66. package/dist/urazuke/receipts.js +65 -3
  67. package/dist/urazuke/receipts.js.map +1 -1
  68. package/dist/urazuke/ruleset.d.ts +56 -0
  69. package/dist/urazuke/ruleset.d.ts.map +1 -0
  70. package/dist/urazuke/ruleset.js +159 -0
  71. package/dist/urazuke/ruleset.js.map +1 -0
  72. package/dist/urazuke/start.d.ts +5 -0
  73. package/dist/urazuke/start.d.ts.map +1 -1
  74. package/dist/urazuke/start.js +39 -2
  75. package/dist/urazuke/start.js.map +1 -1
  76. package/dist/version.d.ts +1 -1
  77. package/dist/version.js +1 -1
  78. package/package.json +1 -1
@@ -0,0 +1,224 @@
1
+ /**
2
+ * LIVE FORGE INSTANCES -- the register that did not exist (PND-161, PND-162,
3
+ * PND-160).
4
+ *
5
+ * `src/tasks/alive.ts` answers "what background TASK is still running" very
6
+ * well: the supervisor knows what it launched. But a chat panel and a
7
+ * `yf start` are not supervisor tasks. Nobody counted them, nobody warned
8
+ * about them, and no command could stop one. The owner found out the way
9
+ * you find out about this class of bug: on 2026-10-21 his machine had nine
10
+ * node processes holding 134 GB of virtual space between them, three of them
11
+ * launched two days earlier, and Windows declared virtual memory exhaustion
12
+ * seven times in eighteen minutes. The identity of the processes being
13
+ * killed was lost with their pids, because Forge kept no register.
14
+ *
15
+ * This module is the same lesson as alive.ts, one level up -- what does not
16
+ * finish has to be VISIBLE, COUNTABLE and STOPPABLE -- applied to the
17
+ * instances themselves. It reads the lock files (the only record an instance
18
+ * leaves) and answers three questions:
19
+ *
20
+ * 1. what is running now, since when, and how much memory does it hold
21
+ * (PND-162: `yf task list` and the panel's startup notice);
22
+ * 2. which previous session died without closing, and at what time
23
+ * (PND-161: the chat notice, dated by the lock's last heartbeat);
24
+ * 3. is anything running from the GLOBAL module right now
25
+ * (PND-160: the global update has to refuse, not warn-and-proceed).
26
+ *
27
+ * Virtual size, not just heap. A node process paged out entirely shows a
28
+ * working set of 0 MB and still counts its full commit charge against the
29
+ * system: that is what trips Windows event 2004, and it is why the heap
30
+ * number alone made the machine look healthy. The heartbeat cannot afford a
31
+ * subprocess, so it records heap and RSS; this module adds the virtual size
32
+ * at LIST time, with one query for every pid at once, and says so when the
33
+ * platform will not give it.
34
+ *
35
+ * ASCII-only.
36
+ */
37
+ import { execFile } from 'node:child_process';
38
+ import { promisify } from 'node:util';
39
+ import { listInstanceLocks } from './instance_lock.js';
40
+ const execFileP = promisify(execFile);
41
+ const MAX_LISTED = 8;
42
+ function toLive(h, now, processStart) {
43
+ return {
44
+ pid: h.pid, port: h.port, root: h.root,
45
+ kind: h.kind ?? 'desconocido',
46
+ ...(h.version ? { version: h.version } : {}),
47
+ started_at: h.since,
48
+ age_minutes: Math.max(0, Math.round((now - h.since) / 60000)),
49
+ ...(h.beat_at != null ? { beat_at: h.beat_at, beat_age_minutes: Math.max(0, Math.round((now - h.beat_at) / 60000)) } : {}),
50
+ ...(h.heap_mb != null ? { heap_mb: h.heap_mb } : {}),
51
+ ...(h.heap_limit_mb != null ? { heap_limit_mb: h.heap_limit_mb } : {}),
52
+ ...(h.rss_mb != null ? { rss_mb: h.rss_mb } : {}),
53
+ ...(h.module_dir ? { module_dir: h.module_dir } : {}),
54
+ from_previous_session: h.since < processStart,
55
+ };
56
+ }
57
+ /** Virtual size in MB per pid, best effort, ONE query for all of them.
58
+ *
59
+ * Windows: Get-Process, which is the only place VirtualMemorySize64 lives.
60
+ * Linux: /proc/<pid>/statm, field 1, in pages. Elsewhere: nothing, and the
61
+ * caller is told it is nothing rather than zero. */
62
+ export async function virtualSizes(pids) {
63
+ const bytes = new Map();
64
+ if (!pids.length)
65
+ return { bytes, unavailable: false };
66
+ try {
67
+ if (process.platform === 'win32') {
68
+ const script = 'Get-Process -Id ' + pids.join(',') + ' -ErrorAction SilentlyContinue'
69
+ + ' | ForEach-Object { "$($_.Id) $($_.VirtualMemorySize64)" }';
70
+ const { stdout } = await execFileP('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true, timeout: 8000 });
71
+ for (const line of stdout.split('\n')) {
72
+ const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
73
+ if (m)
74
+ bytes.set(Number(m[1]), Number(m[2]));
75
+ }
76
+ return { bytes, unavailable: false };
77
+ }
78
+ if (process.platform === 'linux') {
79
+ const fs = await import('node:fs/promises');
80
+ const pageSize = 4096;
81
+ for (const pid of pids) {
82
+ try {
83
+ const statm = await fs.readFile('/proc/' + pid + '/statm', 'utf8');
84
+ const pages = Number(statm.trim().split(/\s+/)[0]);
85
+ if (Number.isFinite(pages))
86
+ bytes.set(pid, pages * pageSize);
87
+ }
88
+ catch { /* gone between the listing and here */ }
89
+ }
90
+ return { bytes, unavailable: false };
91
+ }
92
+ }
93
+ catch { /* the query itself failed: report unavailable, never zero */ }
94
+ return { bytes, unavailable: true };
95
+ }
96
+ /** The instance report. `with_vsz` costs one subprocess, so it is opt-in:
97
+ * the panel's startup notice does not need it, `yf task list` does. */
98
+ export async function instanceReport(opts = {}) {
99
+ const now = opts.now ?? Date.now();
100
+ const processStart = opts.processStart ?? (Date.now() - Math.round(process.uptime() * 1000));
101
+ const { alive, dead } = listInstanceLocks();
102
+ const live = alive.map((h) => toLive(h, now, processStart));
103
+ if (!opts.with_vsz || !live.length)
104
+ return { alive: live, dead };
105
+ const { bytes, unavailable } = await virtualSizes(live.map((i) => i.pid));
106
+ for (const i of live) {
107
+ const b = bytes.get(i.pid);
108
+ if (b != null)
109
+ i.vsz_mb = Math.round(b / (1024 * 1024));
110
+ }
111
+ return { alive: live, dead, ...(unavailable ? { vsz_unavailable: true } : {}) };
112
+ }
113
+ function age(minutes) {
114
+ if (minutes < 60)
115
+ return minutes + ' min';
116
+ const h = Math.floor(minutes / 60);
117
+ return h < 24 ? h + ' h' : Math.floor(h / 24) + ' d';
118
+ }
119
+ function hhmm(ms) {
120
+ const d = new Date(ms);
121
+ const p = (n) => String(n).padStart(2, '0');
122
+ return p(d.getHours()) + ':' + p(d.getMinutes());
123
+ }
124
+ /** The chat line for a PREVIOUS session that died without closing (PND-161).
125
+ *
126
+ * Empty when there is nothing to say -- and that is the hard rule here: an
127
+ * ordered shutdown removes its lock, so it reaches this function as
128
+ * nothing, and produces no message. A notice about the normal case becomes
129
+ * noise and then stops being read, which would cost exactly the signal
130
+ * this was built to give. */
131
+ export function renderDeathNotice(dead) {
132
+ if (!dead.length)
133
+ return '';
134
+ const lines = [];
135
+ for (const d of dead.slice(0, MAX_LISTED)) {
136
+ const what = d.kind === 'start' ? 'El servidor' : 'La sesion';
137
+ if (d.beat_at != null) {
138
+ const mins = Math.max(0, Math.round((d.beat_at - d.since) / 60000));
139
+ lines.push(' - ' + what + ' anterior (PID ' + d.pid + ') se corto cerca de las ' + hhmm(d.beat_at)
140
+ + ' sin cierre ordenado, despues de ' + age(mins) + ' viva.'
141
+ + (d.heap_mb != null && d.heap_limit_mb ? ' Ultimo latido: heap ' + d.heap_mb + '/' + d.heap_limit_mb + ' MB'
142
+ + (d.rss_mb != null ? ', RSS ' + d.rss_mb + ' MB' : '') + '.' : '')
143
+ + (d.memory_pressure ? ' El heap venia arriba del 85% del techo: la muerte es CONSISTENTE con falta de memoria (no es prueba: Forge no ve el kill).' : ''));
144
+ }
145
+ else {
146
+ /* A lock from a Forge without the heartbeat. The death is certain --
147
+ * the lock was never released -- but the hour is not, and inventing
148
+ * one from the file's timestamps would be the same guessing the
149
+ * heartbeat exists to replace. */
150
+ lines.push(' - ' + what + ' anterior (PID ' + d.pid + ') murio sin cierre ordenado. La hora exacta no quedo'
151
+ + ' registrada: ese panel es de una version sin latido.');
152
+ }
153
+ }
154
+ if (dead.length > MAX_LISTED)
155
+ lines.push(' ... y ' + (dead.length - MAX_LISTED) + ' mas');
156
+ return ['[sesion] ' + (dead.length === 1 ? 'La sesion anterior de este proyecto no cerro bien:' : dead.length + ' sesiones anteriores no cerraron bien:'),
157
+ ...lines,
158
+ 'Si estabas en medio de un turno, revisa el ultimo mensaje: lo que el cerebro no alcanzo a guardar no esta.'].join('\n');
159
+ }
160
+ /** The chat line for OTHER instances still running (PND-162). Only the ones
161
+ * from a previous session: the one the owner just opened is not news, and
162
+ * a second panel on another project is legitimate and current. */
163
+ export function renderOldInstancesNotice(alive, selfPid = process.pid) {
164
+ const old = alive.filter((i) => i.pid !== selfPid && i.from_previous_session);
165
+ if (!old.length)
166
+ return '';
167
+ const lines = [
168
+ '[instancias] Hay ' + old.length + ' proceso(s) de Forge de una sesion anterior todavia vivos:',
169
+ ...old.slice(0, MAX_LISTED).map((i) => ' - PID ' + i.pid + ' [' + i.kind + ']'
170
+ + ' hace ' + age(i.age_minutes)
171
+ + (i.rss_mb != null ? ', RSS ' + i.rss_mb + ' MB' : '')
172
+ + (i.vsz_mb != null ? ', virtual ' + i.vsz_mb + ' MB' : '')
173
+ + ' -- ' + i.root),
174
+ ];
175
+ if (old.length > MAX_LISTED)
176
+ lines.push(' ... y ' + (old.length - MAX_LISTED) + ' mas');
177
+ lines.push('Cada uno reserva memoria del sistema aunque no lo uses. Los listas con `yf task list` y los paras con `yf instance stop <pid>`.');
178
+ return lines.join('\n');
179
+ }
180
+ /** Stop a Forge instance by pid, with its whole process tree.
181
+ *
182
+ * Two guards, and both matter. It refuses our own pid, and it refuses any
183
+ * pid that is not in the lock registry -- so this is a way to stop Forge
184
+ * instances, never a general process killer reachable from the chat. On
185
+ * Windows the tree flag is not optional: a panel's real work happens in
186
+ * children, and killing only the parent leaves them holding the memory
187
+ * that was the problem in the first place. */
188
+ export async function stopInstance(pid, selfPid = process.pid) {
189
+ if (!Number.isInteger(pid) || pid <= 0)
190
+ return { stopped: false, reason: 'pid invalido' };
191
+ if (pid === selfPid)
192
+ return { stopped: false, reason: 'es el proceso actual: cerralo normalmente' };
193
+ const { alive } = listInstanceLocks();
194
+ if (!alive.some((h) => h.pid === pid)) {
195
+ return { stopped: false, reason: 'no es una instancia de Forge viva (no hay lock con ese pid)' };
196
+ }
197
+ try {
198
+ if (process.platform === 'win32') {
199
+ await execFileP('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, timeout: 10000 });
200
+ }
201
+ else {
202
+ process.kill(pid, 'SIGTERM');
203
+ }
204
+ return { stopped: true };
205
+ }
206
+ catch (e) {
207
+ return { stopped: false, reason: e instanceof Error ? e.message.split('\n')[0] : String(e) };
208
+ }
209
+ }
210
+ /** Instances running from the GLOBAL module, for the update precheck
211
+ * (PND-160). A panel keeps better_sqlite3.node mapped, and on Windows a
212
+ * mapped file cannot be replaced -- so `npm i -g` deletes the old module,
213
+ * fails to write the new native binary, and aborts leaving the global
214
+ * package as a bare node_modules with no dist and no binaries. `yf` stops
215
+ * existing. The matching is on the lock's own record of where the process
216
+ * came from, so it does not depend on reading another process's argv. */
217
+ export function instancesFromGlobal(alive, globalPrefix, selfPid = process.pid) {
218
+ const norm = (p) => (process.platform === 'win32' ? p.toLowerCase() : p).replace(/[\\/]+$/, '');
219
+ const prefix = norm(globalPrefix);
220
+ if (!prefix)
221
+ return [];
222
+ return alive.filter((i) => i.pid !== selfPid && norm(i.module_dir ?? '').startsWith(prefix));
223
+ }
224
+ //# sourceMappingURL=instances.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instances.js","sourceRoot":"","sources":["../../src/chat/instances.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"panel.d.ts","sourceRoot":"","sources":["../../src/chat/panel.ts"],"names":[],"mappings":"AAmBA,OAAO,EAEL,KAAK,iBAAiB,EAEvB,MAAM,kBAAkB,CAAC;AAG1B,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb;;;;;2BAKuB;IACvB,IAAI,CAAC,EAAE,iBAAiB,CAAC;CAC1B;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,WAAW,GAAG,MAAM,CAu5bxD"}
1
+ {"version":3,"file":"panel.d.ts","sourceRoot":"","sources":["../../src/chat/panel.ts"],"names":[],"mappings":"AAmBA,OAAO,EAEL,KAAK,iBAAiB,EAEvB,MAAM,kBAAkB,CAAC;AAG1B,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb;;;;;2BAKuB;IACvB,IAAI,CAAC,EAAE,iBAAiB,CAAC;CAC1B;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,WAAW,GAAG,MAAM,CAsqcxD"}
@@ -1103,6 +1103,16 @@ body {
1103
1103
  #yf-full .pz-pnd-chip.pnd-prio-cold { border-color: #d4cbb8; color: #8d8470; }
1104
1104
  #yf-full .pz-pnd-chip.pnd-doc { border-color: rgba(111,139,94,0.6); color: #5d7a4c; }
1105
1105
  #yf-full .pz-pnd-chip.pnd-nodoc { border-color: rgba(164,74,54,0.4); color: #a44a36; }
1106
+ /* PND-156: el reporte honesto es OTRO dato que el expediente, y antes
1107
+ * compartia la clase pnd-doc, con lo cual se veian identicos y parecian el
1108
+ * mismo indicador repetido. Indigo sumi-e, no verde. */
1109
+ #yf-full .pz-pnd-chip.pnd-report { border-color: rgba(79,91,135,0.6); color: #4f5b87; background: rgba(79,91,135,0.06); }
1110
+ /* PND-155: control de orden de la lista. */
1111
+ #yf-full .pz-pnd-sort { font: inherit; font-size: 11px; color: var(--ink-muted, #6f6650); background: var(--bg-0, #fffdf7);
1112
+ border: 1px solid #d4cbb8; border-radius: 4px; padding: 2px 4px; cursor: pointer; }
1113
+ #yf-full .pz-pnd-dir { font: inherit; font-size: 11px; width: 24px; color: var(--ink-muted, #6f6650);
1114
+ background: var(--bg-0, #fffdf7); border: 1px solid #d4cbb8; border-radius: 4px; cursor: pointer; padding: 2px 0; }
1115
+ #yf-full .pz-pnd-dir:hover, #yf-full .pz-pnd-sort:hover { border-color: var(--amber, #c8a04d); color: var(--ink, #3b3528); }
1106
1116
  /* Pestana BPMN 2.0 (flujos de proceso). */
1107
1117
  #yf-full .pz-body.pz-bpmn-wrap { background: var(--bg-1, #f5f0e6); padding: 12px; overflow: auto; }
1108
1118
  #yf-full .pz-bpmn-svg { display: block; width: 100%; height: auto; }
@@ -3292,7 +3302,10 @@ let state = {
3292
3302
  { id, title, kind, content, language?, file_path?,
3293
3303
  isEditing?, editValue?, dirty? }
3294
3304
  activeId picks which tab body to render. */
3295
- pizarron: { items: [], activeId: null },
3305
+ /* PND-153: ui = memoria por pestana (foco, caret, scroll); renderedId =
3306
+ * la pestana que esta EN EL DOM, que en un cambio de pestana NO es la
3307
+ * activa -- confundir esas dos era el bug. */
3308
+ pizarron: { items: [], activeId: null, ui: {}, renderedId: null },
3296
3309
  /* alpha.43 F1 -- cached NAC3 manifest + version. Updated on
3297
3310
  boot via GET /api/forge/nac3-manifest and on every chat
3298
3311
  response that carries nac3_manifest_version. Mismatch
@@ -5665,6 +5678,7 @@ function pizarronDropItem(id) {
5665
5678
  const idx = state.pizarron.items.findIndex((x) => x.id === id);
5666
5679
  if (idx < 0) return;
5667
5680
  state.pizarron.items.splice(idx, 1);
5681
+ pzForgetUiState(id); /* PND-153: no acumular memoria de pestanas cerradas */
5668
5682
  if (state.pizarron.activeId === id) {
5669
5683
  const next = state.pizarron.items[state.pizarron.items.length - 1];
5670
5684
  state.pizarron.activeId = next ? next.id : null;
@@ -5737,7 +5751,7 @@ function uzkRenderModal(r) {
5737
5751
  for (var k = 0; k < r.receipts.length; k++) {
5738
5752
  var rc = r.receipts[k];
5739
5753
  h += '<li class="' + (rc.verdict === 'ok' ? 'uzk-ok' : 'uzk-fail') + '">' + escapeHtml(rc.id + ' ' + rc.kind + (rc.pending_id ? ' (' + rc.pending_id + ')' : '') + ' -- ' + (rc.verdict === 'ok' ? 'OK' : 'NOT OK'))
5740
- + '<span class="uzk-det">' + escapeHtml(rc.command + (rc.counts ? ' | ' + rc.counts : '')) + '</span></li>';
5754
+ + '<span class="uzk-det">' + escapeHtml(rc.command + (rc.counts ? ' | ' + rc.counts : '') + (rc.why ? ' | ' + rc.why : '')) + '</span></li>';
5741
5755
  }
5742
5756
  h += '</ul>';
5743
5757
  }
@@ -5847,6 +5861,7 @@ function pizarronCloseItem(id) {
5847
5861
  yfCoding.raf = null;
5848
5862
  }
5849
5863
  state.pizarron.items.splice(idx, 1);
5864
+ pzForgetUiState(id); /* PND-153: no acumular memoria de pestanas cerradas */
5850
5865
  if (state.pizarron.activeId === id) {
5851
5866
  const next = state.pizarron.items[state.pizarron.items.length - 1];
5852
5867
  state.pizarron.activeId = next ? next.id : null;
@@ -6268,14 +6283,146 @@ function pzRestoreScroll(body, snap) {
6268
6283
  try { requestAnimationFrame(apply); } catch (e) { /* entorno sin rAF */ }
6269
6284
  }
6270
6285
 
6286
+ /* ---- PND-153: memoria de UI POR PESTANA (foco, caret, scroll) ----
6287
+ *
6288
+ * Pablo lo planteo como impresion: "me parece que el foco es compartido
6289
+ * entre todas las pestanas, no guarda foco por pestana". Medido en el
6290
+ * codigo, es algo distinto y peor: el foco no se comparte, se DESTRUYE.
6291
+ * renderPizarron reescribe body.innerHTML entero en cada render, asi que
6292
+ * el elemento enfocado deja de existir y el navegador manda el foco al
6293
+ * body. Y el scroll solo se preservaba DENTRO de un mismo render de la
6294
+ * MISMA pestana (pzRestoreScroll sale temprano si cambio activeId), asi
6295
+ * que al volver a una pestana empezaba de cero.
6296
+ *
6297
+ * O sea: no habia estado de UI por pestana, ni de foco ni de scroll. Esto
6298
+ * lo agrega, con una regla: la memoria se guarda contra la pestana que
6299
+ * estaba EN EL DOM, no contra la activa -- en un cambio de pestana esas dos
6300
+ * son distintas, y escribirla contra la activa era el bug de origen. */
6301
+
6302
+ /** La clave con la que se vuelve a encontrar un elemento despues de que el
6303
+ * innerHTML lo destruyo. data-nac-id primero: la doctrina NAC-3 obliga a
6304
+ * que todo elemento interactivo lleve uno, y es estable entre renders.
6305
+ * Si no lo tiene, un camino tag+indice dentro de la tarjeta, que no es
6306
+ * estable si cambia el contenido pero si lo es en un re-render igual. */
6307
+ function pzFocusKey(el, root) {
6308
+ if (!el || !root || !root.contains(el)) return null;
6309
+ var nac = el.getAttribute && el.getAttribute('data-nac-id');
6310
+ /* Un id con comilla no se puede meter en un selector de atributo sin
6311
+ * escaparlo, y escapar aca es imposible: este bloque vive dentro de un
6312
+ * template literal de TypeScript, que se come las barras invertidas. Los
6313
+ * data-nac-id son puntos y alfanumericos por doctrina, asi que el caso no
6314
+ * existe -- y si apareciera, cae al camino estructural. */
6315
+ if (nac && nac.indexOf('"') < 0) return 'nac:' + nac;
6316
+ var parts = [];
6317
+ var cur = el;
6318
+ while (cur && cur !== root && cur.parentNode) {
6319
+ var sibs = cur.parentNode.children || [];
6320
+ var idx = 0;
6321
+ for (var i = 0; i < sibs.length; i++) { if (sibs[i] === cur) { idx = i; break; } }
6322
+ parts.unshift(cur.tagName + '[' + idx + ']');
6323
+ cur = cur.parentNode;
6324
+ }
6325
+ return parts.length ? 'path:' + parts.join('>') : null;
6326
+ }
6327
+
6328
+ /* Sin expresiones regulares y sin una sola barra invertida, a proposito:
6329
+ * este codigo se emite desde un template literal, asi que un \\[ o un \\d
6330
+ * en la fuente llegan al navegador como [ y d -- la primera version de esto
6331
+ * tenia /^(.+)\\[(\\d+)\\]$/ y el panel recibio /^(.+)[(d+)]$/, que casa
6332
+ * cualquier cosa y devolvia el elemento equivocado. El test lo agarro. */
6333
+ function pzFindByFocusKey(key, root) {
6334
+ if (!key || !root) return null;
6335
+ if (key.indexOf('nac:') === 0) {
6336
+ try { return root.querySelector('[data-nac-id="' + key.slice(4) + '"]'); } catch (e) { return null; }
6337
+ }
6338
+ if (key.indexOf('path:') !== 0) return null;
6339
+ var parts = key.slice(5).split('>');
6340
+ var cur = root;
6341
+ for (var i = 0; i < parts.length; i++) {
6342
+ var part = parts[i];
6343
+ var open = part.lastIndexOf('[');
6344
+ if (open <= 0 || part.charAt(part.length - 1) !== ']') return null;
6345
+ var tag = part.slice(0, open);
6346
+ var idx = Number(part.slice(open + 1, part.length - 1));
6347
+ if (!cur || !isFinite(idx)) return null;
6348
+ var kid = (cur.children || [])[idx];
6349
+ if (!kid || kid.tagName !== tag) return null;
6350
+ cur = kid;
6351
+ }
6352
+ return cur === root ? null : cur;
6353
+ }
6354
+
6355
+ /** Lo que el usuario estaba haciendo en la pestana que esta en el DOM. */
6356
+ function pzCaptureUiState(body) {
6357
+ if (!body) return;
6358
+ var tabId = state.pizarron.renderedId;
6359
+ if (!tabId) return;
6360
+ var entry = { };
6361
+ var sc = pzScroller(body);
6362
+ if (sc) {
6363
+ entry.top = sc.scrollTop;
6364
+ entry.atBottom = (sc.scrollHeight - sc.scrollTop - sc.clientHeight) <= PZ_STICK_PX;
6365
+ }
6366
+ try {
6367
+ var act = document.activeElement;
6368
+ var key = pzFocusKey(act, body);
6369
+ if (key) {
6370
+ entry.focusKey = key;
6371
+ /* El caret, no solo "el input estaba enfocado": volver al campo con
6372
+ * el cursor al principio es casi tan molesto como perder el foco. */
6373
+ if (typeof act.selectionStart === 'number') {
6374
+ entry.selStart = act.selectionStart;
6375
+ entry.selEnd = act.selectionEnd;
6376
+ }
6377
+ if (typeof act.scrollTop === 'number') entry.innerScroll = act.scrollTop;
6378
+ }
6379
+ } catch (e) { /* sin DOM de foco: nada que guardar */ }
6380
+ if (!state.pizarron.ui) state.pizarron.ui = {};
6381
+ state.pizarron.ui[tabId] = entry;
6382
+ }
6383
+
6384
+ /** Devolver al usuario donde estaba, en la pestana que se acaba de pintar. */
6385
+ function pzRestoreUiState(body) {
6386
+ var tabId = state.pizarron.activeId;
6387
+ if (!body || !tabId || !state.pizarron.ui) return;
6388
+ var entry = state.pizarron.ui[tabId];
6389
+ if (!entry) return;
6390
+ var apply = function () {
6391
+ var sc = pzScroller(body);
6392
+ if (sc && entry.top != null) sc.scrollTop = entry.atBottom ? sc.scrollHeight : entry.top;
6393
+ if (!entry.focusKey) return;
6394
+ var el = pzFindByFocusKey(entry.focusKey, body);
6395
+ if (!el || typeof el.focus !== 'function') return;
6396
+ /* preventScroll: el foco no debe pelearse con el scroll que acabamos
6397
+ * de restaurar dos lineas arriba. */
6398
+ try { el.focus({ preventScroll: true }); } catch (e) { try { el.focus(); } catch (e2) { return; } }
6399
+ if (entry.selStart != null && typeof el.setSelectionRange === 'function') {
6400
+ try { el.setSelectionRange(entry.selStart, entry.selEnd != null ? entry.selEnd : entry.selStart); } catch (e) { /* input sin seleccion */ }
6401
+ }
6402
+ if (entry.innerScroll != null && typeof el.scrollTop === 'number') el.scrollTop = entry.innerScroll;
6403
+ };
6404
+ apply();
6405
+ try { requestAnimationFrame(apply); } catch (e) { /* entorno sin rAF */ }
6406
+ }
6407
+
6408
+ /** Olvidar la memoria de una pestana cerrada: si no, el mapa crece con
6409
+ * cada pestana que el usuario abre y cierra durante la sesion. */
6410
+ function pzForgetUiState(id) {
6411
+ if (state.pizarron.ui && id in state.pizarron.ui) delete state.pizarron.ui[id];
6412
+ }
6413
+
6271
6414
  function renderPizarron() {
6272
6415
  const tabs = $('#pizarron-tabs');
6273
6416
  const body = $('#pizarron-body');
6274
6417
  const empty = $('#pizarron-empty');
6275
6418
  const count = $('#pizarron-count');
6276
6419
  if (!tabs || !body || !count) return;
6277
- /* Antes de tocar el DOM: donde estaba mirando el usuario. */
6420
+ /* Antes de tocar el DOM: donde estaba mirando el usuario. El snapshot
6421
+ * de scroll intra-render sigue (misma pestana); pzCaptureUiState guarda
6422
+ * ademas foco y caret CONTRA LA PESTANA QUE ESTA EN EL DOM, que es lo que
6423
+ * permite recuperarlos al volver (PND-153). */
6278
6424
  const _pzScroll = pzCaptureScroll(body);
6425
+ pzCaptureUiState(body);
6279
6426
  count.textContent = state.pizarron.items.length + ' ' + (state.pizarron.items.length === 1 ? CONFIG.i18n.tabOne : CONFIG.i18n.tabMany);
6280
6427
  /* Render tabs */
6281
6428
  applySavedTabOrder(); /* PND-137 -- respeta el orden elegido por drag */
@@ -6371,6 +6518,11 @@ function renderPizarron() {
6371
6518
  }
6372
6519
  /* Devolver al usuario donde estaba: pegado al fondo si venia pegado. */
6373
6520
  pzRestoreScroll(body, _pzScroll);
6521
+ /* Y la memoria de ESTA pestana: foco, caret y scroll de la ultima vez que
6522
+ * el usuario estuvo en ella (PND-153). Va despues del scroll intra-render
6523
+ * para que gane el estado propio de la pestana al volver a ella. */
6524
+ state.pizarron.renderedId = state.pizarron.activeId;
6525
+ pzRestoreUiState(body);
6374
6526
  }
6375
6527
 
6376
6528
  /* Selector multi-workflow: persiste el indice elegido por tab id (igual
@@ -6507,6 +6659,21 @@ function renderPendingsView(content) {
6507
6659
  + '<input type="text" class="pz-pnd-q" data-pz-pnd-q spellcheck="false"'
6508
6660
  + ' placeholder="Buscar: id, titulo, archivo, caso de uso..."'
6509
6661
  + ' aria-label="Buscar en la lista" />'
6662
+ /* PND-155: tres criterios, los dos sentidos. "registro" es el orden que
6663
+ * manda el servidor (prioridad), y es el default: elegir orden es una
6664
+ * accion del usuario, no algo que le cambie la lista sin pedirlo. */
6665
+ + '<select class="pz-pnd-sort" data-pz-pnd-sort'
6666
+ + ' data-nac-id="yujin.pending.sort.criterion" data-nac-role="select"'
6667
+ + ' data-nac-action="sort_pendings" aria-label="Ordenar la lista por">'
6668
+ + ' <option value="default">orden del registro</option>'
6669
+ + ' <option value="id">nro de PND</option>'
6670
+ + ' <option value="title">titulo</option>'
6671
+ + ' <option value="date">fecha de ' + (done ? 'cierre' : 'creacion') + '</option>'
6672
+ + '</select>'
6673
+ + '<button type="button" class="pz-pnd-dir" data-pz-pnd-dir'
6674
+ + ' data-nac-id="yujin.pending.sort.direction" data-nac-role="action"'
6675
+ + ' data-nac-action="toggle_sort_direction"'
6676
+ + ' title="Invertir el orden" aria-label="Invertir el orden">&#8597;</button>'
6510
6677
  + '<span class="pz-pnd-hint">click para ver el detalle</span>'
6511
6678
  + '</div>';
6512
6679
  if (!d.items.length) {
@@ -6520,10 +6687,21 @@ function renderPendingsView(content) {
6520
6687
  var meta = done
6521
6688
  ? '<span class="pz-pnd-when">' + escapeHtml(pndFmtDate(it.closed_at)) + '</span>'
6522
6689
  + '<span class="pz-pnd-chip pnd-reason">' + escapeHtml(pndReasonLabel(it.closed_reason)) + '</span>'
6690
+ /* PND-156. Pablo vio "un boton Reporte que hace lo mismo que el
6691
+ * boton doc". Medido: no son botones y no hacen lo mismo -- son dos
6692
+ * indicadores de DOS datos distintos (expediente de implementacion
6693
+ * vs reporte honesto), y parecian el mismo porque compartian la
6694
+ * clase pnd-doc, con lo cual se veian identicos, y porque el unico
6695
+ * elemento clickeable es la FILA entera: apretar sobre cualquiera
6696
+ * de los dos abre el detalle. Hacian lo mismo porque no hacian
6697
+ * nada. Esto es el cambio minimo: cada indicador con su estilo y su
6698
+ * texto propio, sin perder ninguno de los dos datos. Hacerlos
6699
+ * clickeables para que abran su seccion del detalle es una decision
6700
+ * de diseno de Pablo, y queda abierta. */
6523
6701
  + (impl && (impl.functional || impl.use_cases)
6524
- ? '<span class="pz-pnd-chip pnd-doc" title="tiene expediente de implementacion">doc</span>'
6702
+ ? '<span class="pz-pnd-chip pnd-doc" title="tiene expediente de implementacion: que se puede hacer ahora y con que casos de uso">expediente</span>'
6525
6703
  : '<span class="pz-pnd-chip pnd-nodoc" title="cerrado sin descripcion funcional">sin doc</span>')
6526
- + (impl && impl.report ? '<span class="pz-pnd-chip pnd-doc" title="reporte honesto: verificado / creido / punto ciego">reporte</span>' : '')
6704
+ + (impl && impl.report ? '<span class="pz-pnd-chip pnd-report" title="tiene reporte honesto: que quedo verificado, que creido y que es punto ciego">reporte</span>' : '')
6527
6705
  /* PND-154 (Pablo 2026-10-20): la tarjeta de un pendiente ABIERTO
6528
6706
  * muestra su fecha de CREACION, como la de Hecho ya mostraba la de
6529
6707
  * cierre. El dato estaba en el registro desde siempre y no se veia,
@@ -6534,7 +6712,17 @@ function renderPendingsView(content) {
6534
6712
  + '<span class="pz-pnd-chip ' + pndPrioClass(it.priority) + '">' + escapeHtml(String(it.priority != null ? it.priority : '-')) + '</span>'
6535
6713
  + '<span class="pz-pnd-chip pnd-cat">' + escapeHtml(pndCatLabel(it.category)) + '</span>'
6536
6714
  + (it.blocker_who ? '<span class="pz-pnd-chip pnd-who">' + escapeHtml(String(it.blocker_who)) + '</span>' : '');
6715
+ /* PND-155: las claves de orden viajan en la fila, como data-pnd-q ya
6716
+ * hace para el buscador, asi que ordenar es reacomodar nodos en el DOM
6717
+ * -- sin request, sin re-render, y sin pelearse con el refresh de 12 s.
6718
+ * El numero va aparte del id a proposito: ordenar "PND-170" como texto
6719
+ * pone PND-10 antes de PND-9. */
6720
+ var pndNum = (function () { var m = /(\d+)\s*$/.exec(id); return m ? Number(m[1]) : -1; })();
6721
+ var pndTs = Number(done ? (it.closed_at || 0) : (it.created_at || 0)) || 0;
6537
6722
  return '<button type="button" class="pz-pnd-row" data-pz-pnd-row data-pnd-id="' + escapeHtml(id) + '"'
6723
+ + ' data-pnd-num="' + pndNum + '"'
6724
+ + ' data-pnd-ts="' + pndTs + '"'
6725
+ + ' data-pnd-title="' + escapeHtml(String((it && it.title) || '')) + '"'
6538
6726
  + ' data-pnd-q="' + escapeHtml(pendingSearchText(it)) + '"'
6539
6727
  + ' data-nac-id="yujin.pending.' + escapeHtml(id) + '.open"'
6540
6728
  + ' data-nac-role="action" data-nac-action="open_pending_detail">'
@@ -6704,6 +6892,61 @@ function pendingDetailMd(it, done) {
6704
6892
  return L.join('\\n');
6705
6893
  }
6706
6894
 
6895
+ /* ---- PND-155: orden de la lista de Pendientes / Hecho ----
6896
+ *
6897
+ * Tres criterios y los dos sentidos, con dos cuidados que el pedido nombra
6898
+ * explicitamente: el nro de PND se ordena NUMERICO (PND-9 antes de PND-10;
6899
+ * como texto sale al revas) y el titulo con localeCompare en el idioma
6900
+ * configurado, no byte a byte.
6901
+ *
6902
+ * Y uno que el pedido tambien nombra: la eleccion se guarda POR PESTANA
6903
+ * (Pendientes y Hecho son dos listas distintas y cada una tiene su orden
6904
+ * natural), no en una variable global -- que es el agujero de PND-153. */
6905
+ function pndSortKey(mode) {
6906
+ var slug = (CONFIG && (CONFIG.projectSlug || CONFIG.projectName)) || 'default';
6907
+ return 'yf_pnd_sort_' + slug + '_' + (mode || 'open');
6908
+ }
6909
+
6910
+ function pndReadSortPref(mode) {
6911
+ try {
6912
+ var raw = localStorage.getItem(pndSortKey(mode));
6913
+ if (raw) {
6914
+ var p = JSON.parse(raw);
6915
+ if (p && typeof p.by === 'string') return { by: p.by, dir: p.dir === 'desc' ? 'desc' : 'asc' };
6916
+ }
6917
+ } catch (e) { /* sin localStorage: el default alcanza */ }
6918
+ return { by: 'default', dir: 'asc' };
6919
+ }
6920
+
6921
+ function pndWriteSortPref(mode, pref) {
6922
+ try { localStorage.setItem(pndSortKey(mode), JSON.stringify(pref)); } catch (e) { /* no persiste, no importa */ }
6923
+ }
6924
+
6925
+ /** Reordenar las filas que ya estan en el DOM. rows es el orden ORIGINAL
6926
+ * del registro, que es lo que permite volver a 'default' sin recargar. */
6927
+ function pndApplySort(list, rows, pref) {
6928
+ if (!list) return;
6929
+ var arr = [];
6930
+ for (var i = 0; i < rows.length; i++) arr.push(rows[i]);
6931
+ if (pref.by !== 'default') {
6932
+ var lang = (CONFIG && CONFIG.lang) || undefined;
6933
+ var cmp = function (a, b) {
6934
+ if (pref.by === 'id') return Number(a.getAttribute('data-pnd-num')) - Number(b.getAttribute('data-pnd-num'));
6935
+ if (pref.by === 'date') return Number(a.getAttribute('data-pnd-ts')) - Number(b.getAttribute('data-pnd-ts'));
6936
+ var ta = a.getAttribute('data-pnd-title') || '';
6937
+ var tb = b.getAttribute('data-pnd-title') || '';
6938
+ try { return ta.localeCompare(tb, lang); } catch (e) { return ta < tb ? -1 : (ta > tb ? 1 : 0); }
6939
+ };
6940
+ arr.sort(cmp);
6941
+ if (pref.dir === 'desc') arr.reverse();
6942
+ } else if (pref.dir === 'desc') {
6943
+ arr.reverse();
6944
+ }
6945
+ /* appendChild mueve el nodo: no se clona nada, asi que los handlers y el
6946
+ * estado de display del filtro sobreviven al reorden. */
6947
+ for (var j = 0; j < arr.length; j++) list.appendChild(arr[j]);
6948
+ }
6949
+
6707
6950
  function initPendingsTab(it) {
6708
6951
  var body = document.querySelector('[data-nac-id="yujin.pizarron.item.' + it.id + '.body"]');
6709
6952
  if (!body) return;
@@ -6740,6 +6983,33 @@ function initPendingsTab(it) {
6740
6983
  if (ev.key === 'Escape') { q.value = ''; applyFilter(); }
6741
6984
  });
6742
6985
  }
6986
+
6987
+ /* PND-155: el orden. Se aplica al abrir la pestana (con lo que el usuario
6988
+ * eligio la ultima vez) y en cada cambio del control. */
6989
+ var sortSel = body.querySelector('[data-pz-pnd-sort]');
6990
+ var dirBtn = body.querySelector('[data-pz-pnd-dir]');
6991
+ var list = body.querySelector('.pz-pnd-list');
6992
+ if (sortSel && dirBtn && list) {
6993
+ var mode = d.mode === 'done' ? 'done' : 'open';
6994
+ var pref = pndReadSortPref(mode);
6995
+ var paint = function () {
6996
+ sortSel.value = pref.by;
6997
+ dirBtn.textContent = pref.dir === 'desc' ? '↓' : '↑';
6998
+ dirBtn.setAttribute('title', pref.dir === 'desc' ? 'Orden descendente (click para invertir)' : 'Orden ascendente (click para invertir)');
6999
+ pndApplySort(list, rows, pref);
7000
+ };
7001
+ sortSel.addEventListener('change', function () {
7002
+ pref = { by: String(sortSel.value || 'default'), dir: pref.dir };
7003
+ pndWriteSortPref(mode, pref);
7004
+ paint();
7005
+ });
7006
+ dirBtn.addEventListener('click', function () {
7007
+ pref = { by: pref.by, dir: pref.dir === 'desc' ? 'asc' : 'desc' };
7008
+ pndWriteSortPref(mode, pref);
7009
+ paint();
7010
+ });
7011
+ paint();
7012
+ }
6743
7013
  for (var k = 0; k < rows.length; k++) {
6744
7014
  rows[k].addEventListener('click', function () {
6745
7015
  var pid = this.getAttribute('data-pnd-id');
@@ -7449,6 +7719,7 @@ function autoCloseCodingTab() {
7449
7719
  if (idx < 0) return;
7450
7720
  if (yfCoding.raf != null) { try { cancelAnimationFrame(yfCoding.raf); } catch (e) { /* */ } yfCoding.raf = null; }
7451
7721
  state.pizarron.items.splice(idx, 1);
7722
+ pzForgetUiState(CODING_TAB_ID); /* PND-153: no acumular memoria de pestanas cerradas */
7452
7723
  if (state.pizarron.activeId === CODING_TAB_ID) {
7453
7724
  const next = state.pizarron.items[state.pizarron.items.length - 1];
7454
7725
  state.pizarron.activeId = next ? next.id : null;