@kaydash9999/banker-plugins 0.16.1 → 0.16.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.
@@ -9,11 +9,20 @@
9
9
  * a call shows a short description under the list; a press on a step's arrow folds or unfolds its
10
10
  * calls.
11
11
  *
12
+ * Claude 5 models get no task list tool in Claude Code 2.1.296 (only older models, a background
13
+ * job, or CLAUDE_CODE_ENABLE_TODO_TOOLS do), so there prompts are the steps. Above them the pane
14
+ * shows the plan file the session touched last (progress-plan.mjs reads it), as steps of its own.
15
+ * A prompt's harness tags leave its title, and a background task's notice (or a teammate's message)
16
+ * goes on with the last prompt's step instead of becoming one: the step runs again while it does,
17
+ * and keeps its own end and time.
18
+ *
12
19
  * `/progress` and `/progress show` toggle the pane, `on` and `off` set it. The command is
13
20
  * registered `immediate`, so it answers while a turn runs. An engine without mods never loads
14
21
  * this file: commands/progress.md and hooks/progress-fallback.mjs answer there instead.
15
22
  */
16
23
 
24
+ import { cells, clip, fit, isPlanPath, parsePlan, pick, readPrompt } from './progress-plan.mjs';
25
+
17
26
  const COMMAND = 'progress';
18
27
  const PANE = 'banker-progress';
19
28
  const TITLE = '진행 상황';
@@ -22,6 +31,9 @@ const HINT = '단계나 하위 항목을 누르면 설명이 여기에 나옵니
22
31
  const MAX_STEPS = 50;
23
32
  const MAX_KEPT_CALLS = 200;
24
33
  const MAX_SHOWN_CALLS = 30;
34
+ const MAX_SHOWN_PLAN = 20;
35
+ // The calls whose file, when it is a plan file, becomes the plan the pane shows.
36
+ const PLAN_TOUCH = new Set(['Write', 'Edit', 'MultiEdit', 'Read', 'NotebookEdit']);
25
37
  // The id of the step made for work already under way when the module loaded mid-turn.
26
38
  const AT_HAND = 'now';
27
39
  // Bookkeeping calls: they become steps, not calls under a step.
@@ -30,6 +42,13 @@ const STATUS = {
30
42
  running: '진행 중', in_progress: '진행 중', pending: '대기', done: '완료', completed: '완료',
31
43
  stopped: '중단됨', refused: '거절됨', failed: '오류로 끝남',
32
44
  };
45
+ // A plan step's state, as progress-plan.mjs names it.
46
+ const PLAN_STATUS = { completed: '완료', in_progress: '진행 중', pending: '대기', failed: '실패', blocked: '막힘' };
47
+ // A notice's row, by the status it came with.
48
+ const NOTICE = {
49
+ completed: '백그라운드 작업 끝남', failed: '백그라운드 작업 실패', stopped: '백그라운드 작업 중지됨',
50
+ killed: '백그라운드 작업 중지됨', message: '팀원 메시지',
51
+ };
33
52
  // How a prompt's turn ended (turn.complete's reason), as its step's state.
34
53
  const ENDED = { answer: 'done', aborted: 'stopped', refusal: 'refused', error: 'failed' };
35
54
  // Why a hook failed (the kind of the engine's HookFailure), for a failure that brings no message.
@@ -48,15 +67,12 @@ const tally = () => ({ calls: [], count: 0, failed: 0 });
48
67
  const looseStep = () => ({ key: 'loose', title: '작업 목록 밖의 작업', ...tally() });
49
68
 
50
69
  // `rev` counts the task changes (TaskCreate, TaskUpdate) taken in, to tell a stale TaskList or TaskGet.
51
- export const freshState = () => ({ turns: [], tasks: new Map(), todos: null, loose: looseStep(), open: new Set(), selected: null, seq: 0, rev: 0 });
52
-
53
- // One line of plain text, cut to `n` characters. Control characters go too: a pasted escape
54
- // sequence is not text to show.
55
- const clip = (text, n) => {
56
- const s = String(text ?? '').replace(/\p{Cc}+/gu, ' ').replace(/\s+/g, ' ').trim();
57
- const chars = [...s];
58
- return chars.length > n ? `${chars.slice(0, n - 1).join('')}…` : s;
59
- };
70
+ // `plan` is the plan last read from `planPath`, `planShut` whether its steps are folded. `touch` counts
71
+ // the calls that touched a plan file and `taken` is the touch the plan came from, to tell a read that
72
+ // a newer one overtook.
73
+ export const freshState = () => ({ turns: [], tasks: new Map(), todos: null, loose: looseStep(), open: new Set(), selected: null, seq: 0, rev: 0,
74
+ plan: null, planPath: null, planShut: false, touch: 0, taken: 0 });
75
+
60
76
  const tail = (path) => String(path).split(/[\\/]/).filter(Boolean).slice(-2).join('/');
61
77
  const seconds = (ms) => {
62
78
  const all = Math.max(0, Math.round(ms / 1000));
@@ -72,14 +88,39 @@ export function callLabel(e) {
72
88
 
73
89
  // ── what the module learns from the session ──────────────────────────────────
74
90
 
75
- // A prompt as a step. A prompt whose end never came is done where the next one starts.
76
- function addTurn(s, e, now) {
77
- for (const t of s.turns) if (t.status === 'running') Object.assign(t, { status: 'done', endedAt: now });
78
- s.turns.push({ key: `t-${e.turnId}`, id: e.turnId, title: clip(e.text, 60) || '(이어서 진행)', text: String(e.text ?? ''),
79
- startedAt: now, endedAt: null, status: 'running', ...tally() });
91
+ // A prompt as a step. A prompt whose end never came is done where the next one starts, and so are
92
+ // the notices still running on a step. `notices` holds the turns of the notices running on it.
93
+ function addTurn(s, turnId, text, now, title) {
94
+ for (const t of s.turns) {
95
+ if (t.status === 'running') Object.assign(t, { status: 'done', endedAt: now });
96
+ t.notices.clear();
97
+ }
98
+ s.turns.push({ key: `t-${turnId}`, id: turnId, title: title ?? (clip(text, 60) || '(이어서 진행)'), text,
99
+ startedAt: now, endedAt: null, status: 'running', notices: new Set(), ...tally() });
80
100
  if (s.turns.length > MAX_STEPS) s.turns.shift();
81
101
  }
82
102
 
103
+ // A step's state on the pane: a prompt's step runs again while a notice runs on it.
104
+ const statusOf = (step) => (step.notices?.size ? 'running' : step.status);
105
+
106
+ // A row under a step that is no tool call: it counts as none. `ok` false shows it failed.
107
+ function note(s, step, label, ok, now) {
108
+ step.calls.push({ key: `c-${++s.seq}`, tool: '', label: clip(label, 60), detail: clip(label, 240), isNote: true,
109
+ startedAt: now, endedAt: now, ok, steps: [step] });
110
+ if (step.calls.length > MAX_KEPT_CALLS) step.calls.shift();
111
+ }
112
+
113
+ // A background task's end (or a teammate's message) opens a turn of its own: it runs on the last
114
+ // prompt's step, under which a row says so, and the list stays as it is. Before any prompt it is a
115
+ // step of its own.
116
+ function noticeTurn(s, e, said, now) {
117
+ const word = pick(NOTICE, said.status) ?? '백그라운드 작업 알림';
118
+ if (!s.turns.length) addTurn(s, e.turnId, '', now, said.status === 'message' ? word : '백그라운드 작업 알림');
119
+ const t = s.turns.at(-1);
120
+ if (t.id !== e.turnId) t.notices.add(e.turnId);
121
+ if (said.text) note(s, t, `${word}: ${said.text}`, said.status !== 'failed', now);
122
+ }
123
+
83
124
  // The task list's items, which are the steps while there are any: TodoWrite's, else the Tasks list's.
84
125
  function listItems(s) {
85
126
  return s.todos?.length ? s.todos : [...s.tasks.values()];
@@ -97,14 +138,22 @@ function dropFinished(s) {
97
138
  }
98
139
 
99
140
  export function startTurn(s, e, now) {
141
+ const said = readPrompt(e.text);
142
+ if (said.kind === 'notice') return noticeTurn(s, e, said, now);
100
143
  dropFinished(s);
101
- addTurn(s, e, now);
144
+ addTurn(s, e.turnId, said.text, now);
102
145
  }
103
146
 
104
- // A main turn's end; a subagent's (it carries an agentId) ends no step. A turn the module never
105
- // saw start (it loaded mid-turn) ends the step for the work at hand.
147
+ // A main turn's end; a subagent's (it carries an agentId) ends no step, and a notice's leaves its
148
+ // step's own end as it was. A turn the module never saw start (it loaded mid-turn) ends the step for
149
+ // the work at hand.
106
150
  export function endTurn(s, e, now) {
107
151
  if (e.agentId) return;
152
+ const host = s.turns.find((x) => x.notices.has(e.turnId));
153
+ if (host) {
154
+ host.notices.delete(e.turnId);
155
+ return;
156
+ }
108
157
  const t = s.turns.find((x) => x.id === e.turnId) ?? s.turns.find((x) => x.id === AT_HAND && x.status === 'running');
109
158
  if (!t) return;
110
159
  t.status = ENDED[e.reason] ?? 'failed';
@@ -118,7 +167,7 @@ function owners(s, now) {
118
167
  const items = listItems(s);
119
168
  const task = [...items].reverse().find((t) => t.status === 'in_progress');
120
169
  const first = task ?? (items.length ? s.loose : null);
121
- if (!s.turns.length) addTurn(s, { turnId: AT_HAND, text: '지금 하는 작업' }, now);
170
+ if (!s.turns.length) addTurn(s, AT_HAND, '지금 하는 작업', now);
122
171
  return first ? [first, s.turns.at(-1)] : [s.turns.at(-1)];
123
172
  }
124
173
 
@@ -249,6 +298,50 @@ export function noteTaskTool(s, e, r, now, since) {
249
298
  if (CHANGES.has(e.tool)) s.rev += 1;
250
299
  }
251
300
 
301
+ // The plan file a call touched, with the touch's count, when it is one and the call went through:
302
+ // a write by anyone, a read by the session itself (an agent reviewing a plan does not make it the plan).
303
+ export function planTouched(s, e, call) {
304
+ const path = e.file_path ?? e.notebook_path;
305
+ if (!call?.ok || !PLAN_TOUCH.has(e.tool) || !isPlanPath(path) || (e.tool === 'Read' && e.agentId)) return null;
306
+ s.touch += 1;
307
+ return { path, touch: s.touch };
308
+ }
309
+
310
+ function parsed(path, text) {
311
+ try {
312
+ return typeof text === 'string' ? parsePlan(path, text) : null;
313
+ } catch {
314
+ return null; /* a plan the parser trips on leaves the pane as it was */
315
+ }
316
+ }
317
+
318
+ // Whether a read may change the plan: a read for a touch newer than the one the plan came from, or a
319
+ // read again of the plan's own file (begun when `since` touches were counted) that no touch taken
320
+ // in the meantime overtook.
321
+ function current(s, path, { touch, since }) {
322
+ if (touch !== undefined) return touch > s.taken;
323
+ return path === s.planPath && s.taken <= since;
324
+ }
325
+
326
+ const isPlanKey = (key) => key === 'plan' || /^p-\d+$/.test(key ?? '');
327
+
328
+ // Whether what was read stays off the pane: no plan, or a file with no steps in place of a plan that
329
+ // has some (a review written beside it).
330
+ const keepsOff = (s, path, plan) => !plan || (path !== s.planPath && !plan.total && Boolean(s.plan?.total));
331
+
332
+ // What a read of a plan file found (`read` is `{ touch }` for a touch's read, `{ since }` for a read
333
+ // again). A file that cannot be read or is no plan leaves the plan as it was.
334
+ export function takePlan(s, path, text, read = {}) {
335
+ if (!current(s, path, read)) return;
336
+ const plan = parsed(path, text);
337
+ if (keepsOff(s, path, plan)) return;
338
+ const moving = path !== s.planPath;
339
+ if (read.touch !== undefined) s.taken = read.touch;
340
+ if (moving && isPlanKey(s.selected)) s.selected = null;
341
+ s.planPath = path;
342
+ s.plan = { ...plan, path, steps: plan.steps.map((st, i) => ({ ...st, key: `p-${i}`, planStep: true })) };
343
+ }
344
+
252
345
  // ── what the pane shows ──────────────────────────────────────────────────────
253
346
 
254
347
  // Nothing seen yet: one step that says so, with no state word beside it.
@@ -257,7 +350,8 @@ const IDLE = { key: 'idle', title: '진행 중인 작업 없음', status: null,
257
350
  // The calls outside the list are in progress while calls still go there: no task is in progress
258
351
  // and the prompt runs.
259
352
  function looseStatus(s, items) {
260
- const going = !items.some((t) => t.status === 'in_progress') && s.turns.at(-1)?.status === 'running';
353
+ const last = s.turns.at(-1);
354
+ const going = !items.some((t) => t.status === 'in_progress') && last !== undefined && statusOf(last) === 'running';
261
355
  return going ? 'running' : 'done';
262
356
  }
263
357
 
@@ -272,9 +366,10 @@ function callCount(step) {
272
366
  }
273
367
 
274
368
  function stepState(step, now) {
275
- const doing = step.status === 'in_progress' && step.activeForm ? `(${clip(step.activeForm, 40)})` : '';
369
+ const status = statusOf(step);
370
+ const doing = status === 'in_progress' && step.activeForm ? `(${clip(step.activeForm, 40)})` : '';
276
371
  const time = Number.isFinite(step.startedAt) ? `, ${seconds((step.endedAt ?? now) - step.startedAt)}` : '';
277
- return `${STATUS[step.status] ?? step.status}${doing}${time}`;
372
+ return `${STATUS[status] ?? status}${doing}${time}`;
278
373
  }
279
374
 
280
375
  function stepWords(step, now) {
@@ -285,54 +380,127 @@ function stepWords(step, now) {
285
380
  }
286
381
 
287
382
  function callWords(call, now) {
383
+ if (call.isNote) return `${call.detail.replace(/[.!?。]+$/, '')}.`;
288
384
  const state = call.ok === null ? '실행 중' : call.ok ? '성공' : '실패';
289
385
  const who = call.isAgent ? '하위 에이전트의 ' : '';
290
386
  return `${who}${call.tool}${call.detail ? `: ${call.detail}` : ''}. ${state}, ${seconds((call.endedAt ?? now) - call.startedAt)}.`;
291
387
  }
292
388
 
293
- // What the person pressed last, while it is still on the pane: a step, or a call under one.
389
+ function planWords(plan) {
390
+ const where = `계획 파일: ${clip(plan.path.split(/[\\/]/).filter(Boolean).slice(-3).join('/'), 120)}`;
391
+ const cut = plan.cut ? ' 파일이 길어 앞부분만 읽었습니다.' : '';
392
+ if (!plan.total) return `${where}. 단계를 찾지 못했습니다.${cut}`;
393
+ const done = plan.hasStates ? `, 완료 ${plan.done}개` : '';
394
+ return `${where}. 단계 ${plan.total}개${done}.${cut}`;
395
+ }
396
+
397
+ function planStepWords(st) {
398
+ const what = st.text.replace(/[.!?。]+$/, '');
399
+ return st.status ? `${what}. ${PLAN_STATUS[st.status]}.` : `${what}.`;
400
+ }
401
+
402
+ // What the person pressed last, while it is still on the pane: the plan or one of its steps, a
403
+ // step, or a call under one.
294
404
  function pressed(s) {
405
+ if (s.plan && s.selected === 'plan') return { key: 'plan', isPlan: true };
406
+ const plan = s.plan?.steps.find((st) => st.key === s.selected);
407
+ if (plan) return plan;
295
408
  const all = steps(s);
296
409
  return all.find((x) => x.key === s.selected) ?? all.flatMap((x) => x.calls).find((c) => c.key === s.selected);
297
410
  }
298
411
 
412
+ function itemWords(s, item, now) {
413
+ if (item.isPlan) return planWords(s.plan);
414
+ if (item.planStep) return planStepWords(item);
415
+ return item.tool !== undefined ? callWords(item, now) : stepWords(item, now);
416
+ }
417
+
299
418
  // The description under the list: the pressed step's or call's, or how to get one.
300
419
  export function describe(s, now) {
301
420
  const item = pressed(s);
302
- if (item) return item.tool ? callWords(item, now) : stepWords(item, now);
421
+ if (item) return itemWords(s, item, now);
303
422
  const all = steps(s);
304
423
  return all.length === 1 && all[0].key === 'idle' ? stepWords(IDLE, now) : HINT;
305
424
  }
306
425
 
426
+ // A label cut so that it, the cells before it and the words after it fit on one row of the pane.
427
+ const room = (label, act, before, after) => fit(label, (act.cols ?? Infinity) - before - (after ? cells(after) + 1 : 0));
428
+
307
429
  function callRows(ui, step, act) {
308
430
  const { Box, Text, Button } = ui;
309
431
  const shown = step.calls.slice(-MAX_SHOWN_CALLS);
310
- const rows = shown.map((c) => Box({ key: `row-${c.key}`, flexDirection: 'row', columnGap: 1, paddingLeft: 4, children: [
311
- Button({ key: c.key, label: c.label, plain: true, dimColor: c.ok !== false, onPress: () => act.select(c.key) }),
312
- Text({ children: [c.ok === null ? '실행 중' : c.ok ? '' : '실패'], dimColor: true }),
313
- ] }));
432
+ const rows = shown.map((c) => {
433
+ const state = c.ok === null ? '실행 중' : c.ok ? '' : '실패';
434
+ return Box({ key: `row-${c.key}`, flexDirection: 'row', columnGap: 1, paddingLeft: 4, children: [
435
+ Button({ key: c.key, label: room(c.label, act, 4, state), plain: true, dimColor: c.ok !== false, onPress: () => act.select(c.key) }),
436
+ ...stateWord(ui, state, false),
437
+ ] });
438
+ });
314
439
  const hidden = step.calls.length - shown.length;
315
440
  return hidden ? [Box({ paddingLeft: 4, children: [Text({ children: [`앞의 ${hidden}개는 줄였습니다`], dimColor: true })] }), ...rows] : rows;
316
441
  }
317
442
 
443
+ // A row's state word, bold while it runs; none where the state is empty, so no gap is kept for it.
444
+ function stateWord(ui, state, running) {
445
+ return state ? [ui.Text({ children: [state], dimColor: !running, bold: running })] : [];
446
+ }
447
+
318
448
  function stepRow(ui, s, step, act) {
319
449
  const { Box, Text, Button } = ui;
320
450
  const isOpen = s.open.has(step.key);
321
451
  const arrow = step.calls.length
322
452
  ? Button({ key: `arrow-${step.key}`, label: isOpen ? '▾' : '▸', plain: true, onPress: () => act.fold(step.key) })
323
453
  : Text({ children: [' '] });
324
- const running = step.status === 'running' || step.status === 'in_progress';
454
+ const status = statusOf(step);
455
+ const state = STATUS[status] ?? status ?? '';
325
456
  return Box({ key: `row-${step.key}`, flexDirection: 'row', columnGap: 1, children: [
326
457
  arrow,
327
- Button({ key: step.key, label: step.title, plain: true, onPress: () => act.select(step.key) }),
328
- Text({ children: [STATUS[step.status] ?? step.status ?? ''], dimColor: !running, bold: running }),
458
+ Button({ key: step.key, label: room(step.title, act, 2, state), plain: true, onPress: () => act.select(step.key) }),
459
+ ...stateWord(ui, state, status === 'running' || status === 'in_progress'),
460
+ ] });
461
+ }
462
+
463
+ // The plan's row: its title in bold (a Button draws its Text children in place of its label) and
464
+ // how far along it is.
465
+ function planHead(ui, s, act) {
466
+ const { Box, Text, Button } = ui;
467
+ const { plan } = s;
468
+ const count = !plan.total ? '단계 없음' : plan.hasStates ? `${plan.done}/${plan.total} 완료` : `${plan.total}단계`;
469
+ const arrow = plan.steps.length
470
+ ? Button({ key: 'arrow-plan', label: s.planShut ? '▸' : '▾', plain: true, onPress: () => act.foldPlan() })
471
+ : Text({ children: [' '] });
472
+ const label = room(`계획: ${plan.title}`, act, 2, count);
473
+ return Box({ key: 'row-plan', flexDirection: 'row', columnGap: 1, children: [
474
+ arrow,
475
+ Button({ key: 'plan', label, plain: true, onPress: () => act.select('plan'), children: [Text({ bold: true, children: [label] })] }),
476
+ Text({ children: [count], dimColor: true }),
329
477
  ] });
330
478
  }
331
479
 
332
- // The pane's tree. `ui` is the surface's element table; `act` holds what a press does.
480
+ // The plan's rows: its title, then its first steps unless folded, then a line for the plan's rest.
481
+ function planRows(ui, s, act) {
482
+ const { Box, Text, Button } = ui;
483
+ if (!s.plan) return [];
484
+ const rows = [planHead(ui, s, act)];
485
+ if (s.planShut) return [...rows, Box({ key: 'plan-gap', marginBottom: 1 })];
486
+ const shown = s.plan.steps.slice(0, MAX_SHOWN_PLAN);
487
+ for (const st of shown) {
488
+ const state = PLAN_STATUS[st.status] ?? '';
489
+ rows.push(Box({ key: `row-${st.key}`, flexDirection: 'row', columnGap: 1, paddingLeft: 2, children: [
490
+ Button({ key: st.key, label: room(st.title, act, 2, state), plain: true, onPress: () => act.select(st.key) }),
491
+ ...stateWord(ui, state, st.status === 'in_progress'),
492
+ ] }));
493
+ }
494
+ const rest = s.plan.total - shown.length;
495
+ if (rest > 0) rows.push(Box({ key: 'plan-more', paddingLeft: 2, children: [Text({ children: [`외 ${rest}개`], dimColor: true })] }));
496
+ return [...rows, Box({ key: 'plan-gap', marginBottom: 1 })];
497
+ }
498
+
499
+ // The pane's tree. `ui` is the surface's element table; `act` holds what a press does and `cols`,
500
+ // the cells across the pane's body (Infinity where the engine gives none).
333
501
  export function draw(ui, s, now, act) {
334
502
  const { Box, Text } = ui;
335
- const rows = [];
503
+ const rows = planRows(ui, s, act);
336
504
  for (const step of steps(s)) {
337
505
  rows.push(stepRow(ui, s, step, act));
338
506
  if (s.open.has(step.key)) rows.push(...callRows(ui, step, act));
@@ -385,10 +553,21 @@ async function watchCall($, e, next) {
385
553
  } finally {
386
554
  endCall(call, r, Date.now());
387
555
  if (TASK_TOOLS.has(e.tool)) noteTaskTool(s, e, r, Date.now(), since);
556
+ const touched = planTouched(s, e, call);
557
+ if (touched) takePlan(s, touched.path, await readText($, touched.path), { touch: touched.touch });
388
558
  $.ui.invalidate('ui.render');
389
559
  }
390
560
  }
391
561
 
562
+ // A file's text, or null where it cannot be read (missing, over the engine's 4 MiB, refused).
563
+ async function readText($, path) {
564
+ try {
565
+ return await $.fs.read(path);
566
+ } catch {
567
+ return null;
568
+ }
569
+ }
570
+
392
571
  // The session's progress, one per loaded module.
393
572
  const ref = { s: freshState() };
394
573
 
@@ -405,9 +584,12 @@ export function registerProgress(on) {
405
584
  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
406
585
  if (e.requestId !== PANE) return next(e);
407
586
  const redraw = () => $.ui.invalidate('ui.render');
587
+ const cols = Number.isFinite(e.props?.bodyColumns) ? e.props.bodyColumns : Infinity;
408
588
  const act = {
589
+ cols,
409
590
  select: (key) => { ref.s.selected = ref.s.selected === key ? null : key; redraw(); },
410
591
  fold: (key) => { if (!ref.s.open.delete(key)) ref.s.open.add(key); redraw(); },
592
+ foldPlan: () => { ref.s.planShut = !ref.s.planShut; redraw(); },
411
593
  };
412
594
  return draw($.ui.resolve(e), ref.s, Date.now(), act);
413
595
  });
@@ -425,3 +607,13 @@ export function progressTurnStarted(e) {
425
607
  export function progressTurnCompleted(e) {
426
608
  endTurn(ref.s, e, Date.now());
427
609
  }
610
+
611
+ // The plan file to read again as a turn starts and ends (register.mjs reads it), with the touches
612
+ // counted when the read begins; null without a plan.
613
+ export function progressPlanTarget() {
614
+ return ref.s.planPath ? { path: ref.s.planPath, since: ref.s.touch } : null;
615
+ }
616
+
617
+ export function progressPlanRead(target, text) {
618
+ takePlan(ref.s, target.path, text, { since: target.since });
619
+ }
@@ -16,7 +16,8 @@ import {
16
16
  registerGracefulPause,
17
17
  } from './graceful-pause.mjs';
18
18
  import {
19
- SPEC as PROGRESS_SPEC, progressReset, progressTurnCompleted, progressTurnStarted, registerProgress,
19
+ SPEC as PROGRESS_SPEC, progressPlanRead, progressPlanTarget, progressReset, progressTurnCompleted, progressTurnStarted,
20
+ registerProgress,
20
21
  } from './progress.mjs';
21
22
 
22
23
  async function engineVersion($) {
@@ -45,6 +46,20 @@ async function registerCommands($) {
45
46
  await registerOne($, PROGRESS_SPEC);
46
47
  }
47
48
 
49
+ // /progress's plan file, read again at a turn's start and end: a ralph PRD or a plan's checkboxes
50
+ // change without a call this module sees. A read that fails keeps the plan as it was.
51
+ async function rereadPlan($) {
52
+ const target = progressPlanTarget();
53
+ if (!target) return;
54
+ let text = null;
55
+ try {
56
+ text = await $.fs.read(target.path);
57
+ } catch {
58
+ /* missing or refused: the pane keeps the plan it read last */
59
+ }
60
+ progressPlanRead(target, text);
61
+ }
62
+
48
63
  // A /graceful-pause request that came after its turn's last model request: a note voids it.
49
64
  async function voidLateRequest($, late) {
50
65
  try {
@@ -75,6 +90,7 @@ export const register = (on) => {
75
90
  on('turn.start', async ($, e, next) => {
76
91
  pauseTurnStarted(e);
77
92
  progressTurnStarted(e);
93
+ await rereadPlan($);
78
94
  $.ui.invalidate('ui.render');
79
95
  return next(e);
80
96
  });
@@ -83,6 +99,7 @@ export const register = (on) => {
83
99
  const late = pauseTurnCompleted(e);
84
100
  if (late) await voidLateRequest($, late);
85
101
  progressTurnCompleted(e);
102
+ await rereadPlan($);
86
103
  $.ui.invalidate('ui.render');
87
104
  return next(e);
88
105
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
4
4
  "description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
5
5
  "bin": {
6
6
  "banker": "bin/banker.js"
@@ -50,11 +50,12 @@ description: "스크롤 위치에 맞춰 3D 플라이스루 영상이 움직이
50
50
  ```js
51
51
  import { resolveCreds, resolveFfmpeg } from './references/azure-adapter.mjs';
52
52
  const creds = resolveCreds({ projectDir }); // _source === null 이면 미설정
53
- // setup 이 ffmpeg 경로를 creds 에 FFMPEG_PATH 로 영속했으면 이 프로세스 env 에 적용(다른 세션/no-PATH 대비)
54
- if (creds.FFMPEG_PATH && !process.env.FFMPEG_PATH) process.env.FFMPEG_PATH = creds.FFMPEG_PATH;
53
+ // ffmpeg 경로는 resolveFfmpeg 가 셸의 FFMPEG_PATH 와 홈의 설정 파일, 키 파일에서만 찾는다. 프로젝트 키 파일의 값은 쓰지 않는다.
54
+ if (creds._ignored) console.log('프로젝트 키 파일에서 쓰지 않는 값:', Object.keys(creds._ignored).join(', ')); // 값은 출력하지 않는다
55
55
  ```
56
56
 
57
57
  - `creds._source` 가 `null` 이면 크리덴셜이 없다. `3d-intro-setup` 으로 보낸다.
58
+ - `creds._ignored` 가 있으면 프로젝트 키 파일의 `FFMPEG_PATH` 를 쓰지 않은 것이다. 저장소와 함께 온 파일이 실행할 프로그램을 정하지 못하게 막은 것이다. 값은 출력하지 않으니 사람이 파일을 열어 확인하고, 그 경로가 필요하면 `3d-intro-setup` §2 로 홈 설정 파일에 남긴다.
58
59
  Claude Code: `/banker:setup` 에서 3d-intro-setup / Codex: `banker-3d-intro-setup`.
59
60
  - `resolveFfmpeg()` 가 throw 하면 ffmpeg 가 없다. 같은 `3d-intro-setup` 으로 보낸다.
60
61
  - 둘 다 통과해야 다음 단계로 간다.
@@ -64,7 +65,7 @@ if (creds.FFMPEG_PATH && !process.env.FFMPEG_PATH) process.env.FFMPEG_PATH = cre
64
65
  `malformed` 는 endpoint 가 https URL 이 아니거나(http 는 이 기기의 localhost 만 허용) 모델이 한 id 가 아닌 항목이다(`reason` 이 `endpoint` 또는 `model`). 영상 단계는 이 항목을 건너뛰고, 모두 `malformed` 면 영상 단계에서 멈춘다. 그러니 유료 스틸을 만들기 전에 사용자에게 알리고 `3d-intro-setup` 으로 고친다.
65
66
  Azure 호출도 같다. 키는 https 로만 보내고, `http://` endpoint 는 보내기 전에 막는다(Sora 영상 단계는 `invalid_request`, 이미지 생성 같은 그 밖의 호출은 `HTTP 0 (EINSECURE)` 오류).
66
67
  WAN 항목(`WAN_<n>_ENDPOINT`, `WAN_<n>_API_KEY`)이 하나도 없으면 영상은 처음부터 Sora 로 만든다. 이때 정지와 비행 모드(Step 4B)는 쓸 수 없다.
67
- - 독립 검증(Step 6)에는 Node 용 playwright 패키지와 Chromium 이 필요하다. `npx playwright install chromium`(`setup-playwright`)은 브라우저만 받으므로, 패키지도 `npm i -g playwright` 나 프로젝트의 `npm i -D playwright` 로 설치해 둔다.
68
+ - 독립 검증(Step 6)에는 Node 용 playwright 패키지와 Chromium 이 필요하다. `npx playwright install chromium`(`setup-playwright`)은 브라우저만 받으므로, 패키지도 `npm i -g playwright` 로 설치해 둔다. 사용자가 자기 프로젝트에 `npm i -D playwright` 로 설치했다고 확인해 준 때만 Step 6 에 `--playwright-from-project` 를 준다. 프로젝트에 `node_modules/playwright` 가 있다는 이유만으로는 주지 않는다. 이 옵션이 없으면 프로젝트의 `node_modules` 는 읽지 않는다(저장소가 함께 가져온 코드가 실행되지 않게).
68
69
 
69
70
  ---
70
71