@alfacapital-tech/c4builder 0.9.0-rc.1 → 0.9.0-rc.2

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.
@@ -0,0 +1,21 @@
1
+ const AGENT_VARS = [
2
+ ['CLAUDECODE', 'claude-code'],
3
+ ['CODEX_THREAD_ID', 'codex'],
4
+ ['GEMINI_CLI', 'gemini-cli'],
5
+ ['OPENCODE', 'opencode'],
6
+ ['COPILOT_CLI', 'copilot-cli']
7
+ ];
8
+ const fromEnv = (env) => {
9
+ const own = env.C4BUILDER_AGENT;
10
+ if (own)
11
+ return { on: own !== '0', name: own, explicit: true };
12
+ const known = AGENT_VARS.find(([key]) => env[key]);
13
+ const name = known ? known[1] : env.AI_AGENT;
14
+ return { on: !!name, name: name ?? '', explicit: false };
15
+ };
16
+ export const detectAgent = (env, flags) => {
17
+ const found = fromEnv(env);
18
+ if (flags.agent === undefined)
19
+ return found;
20
+ return { on: flags.agent, name: found.on ? found.name : 'agent', explicit: true };
21
+ };
@@ -0,0 +1,45 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'node:fs';
3
+ import { parseConfig } from "../../config/options.js";
4
+ import { FEEDBACK_HEADER, FEEDBACK_PATH } from "./site.comments.js";
5
+ // `c4builder feedback` (change site-agent-feedback, design D9): забирает пачку замечаний у `site -w`.
6
+ // Конфиг читается как JSON без Configstore — тот создал бы `.c4builder` в любом каталоге. Ошибки —
7
+ // через exitCode, а не process.exit: напечатанная в pipe пачка должна успеть уйти целиком.
8
+ export default async (configPath, opts) => {
9
+ let raw;
10
+ try {
11
+ raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
12
+ }
13
+ catch { }
14
+ const config = parseConfig(raw && typeof raw === 'object' ? raw : {});
15
+ const port = opts.port ?? (config.ok ? config.value : config.salvaged).webPort;
16
+ const shown = `http://localhost:${port}`;
17
+ const fail = (message) => {
18
+ console.error(chalk.red(message));
19
+ process.exitCode = 1;
20
+ };
21
+ // 127.0.0.1, а не localhost: сервер слушает только IPv4, а `localhost` → `::1` на части машин
22
+ // подвешивает соединение.
23
+ const url = `http://127.0.0.1:${port}${FEEDBACK_PATH}/next${opts.wait ? '' : '?wait=0'}`;
24
+ for (;;) {
25
+ let res;
26
+ try {
27
+ res = await fetch(url, { headers: { [FEEDBACK_HEADER]: '1' } });
28
+ }
29
+ catch {
30
+ return fail(`no c4builder site -w on ${shown} — start it first`);
31
+ }
32
+ if (res.status === 200) {
33
+ process.stdout.write(await res.text());
34
+ return;
35
+ }
36
+ if (res.status === 204) {
37
+ if (opts.wait)
38
+ continue;
39
+ return;
40
+ }
41
+ if (res.status === 404)
42
+ return fail(`agent feedback is disabled on ${shown} — the server runs without -w or with --no-agent`);
43
+ return fail(`unexpected response ${res.status} from ${shown}`);
44
+ }
45
+ };
@@ -1,17 +1,32 @@
1
1
  export const COMMENTS_PATH = '/__c4builder/comments';
2
+ export const FEEDBACK_PATH = '/__c4builder/feedback';
3
+ export const FEEDBACK_HEADER = 'X-C4builder-Feedback';
2
4
  const CSS = [
3
- '.c4b-cbtn{position:absolute;z-index:1001;padding:4px 10px;border:1px solid #42b983;border-radius:4px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.15)}',
5
+ '.c4b-cbtns{position:absolute;z-index:1001;white-space:nowrap}',
6
+ '.c4b-cbtns button{margin-right:4px;padding:4px 10px;border:1px solid #42b983;border-radius:4px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.15)}',
7
+ '.c4b-cbtns button.c4b-fb{border-color:#3b82f6}',
4
8
  '.c4b-cpop{position:absolute;z-index:1002;width:380px;max-width:calc(100vw - 32px);padding:10px;border:1px solid #ddd;border-radius:6px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.2)}',
5
9
  '.c4b-cpop blockquote{margin:0 0 8px;padding:4px 8px;border-left:3px solid #42b983;background:#f8f8f8;max-height:80px;overflow:auto;font-size:12px;white-space:pre-wrap}',
6
- '.c4b-cpop textarea{width:100%;box-sizing:border-box;min-height:70px;padding:6px;border:1px solid #ccc;border-radius:4px;font:13px/1.4 sans-serif;resize:vertical}',
10
+ '.c4b-cpop textarea,.c4b-review textarea{width:100%;box-sizing:border-box;min-height:70px;padding:6px;border:1px solid #ccc;border-radius:4px;font:13px/1.4 sans-serif;resize:vertical}',
7
11
  '.c4b-cpop .c4b-row{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:8px}',
8
12
  '.c4b-cpop small{color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
9
- '.c4b-cpop button{padding:4px 12px;border:1px solid #ccc;border-radius:4px;background:#fff;color:#2c3e50;cursor:pointer;font:13px sans-serif}',
10
- '.c4b-cpop button.c4b-primary{border-color:#42b983;background:#42b983;color:#fff}',
11
- '.c4b-cpop button:disabled{opacity:.6;cursor:default}',
13
+ '.c4b-cpop button,.c4b-review button{padding:4px 12px;border:1px solid #ccc;border-radius:4px;background:#fff;color:#2c3e50;cursor:pointer;font:13px sans-serif}',
14
+ '.c4b-cpop button.c4b-primary,.c4b-review button.c4b-primary{border-color:#42b983;background:#42b983;color:#fff}',
15
+ '.c4b-cpop button:disabled,.c4b-review button:disabled{opacity:.6;cursor:default}',
16
+ // Панель review — ниже кнопок у выделения и поповера: она прибита к низу экрана и иначе
17
+ // перекрывает их у выделения в нижних строках.
18
+ '.c4b-review{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:1000;width:520px;max-width:calc(100vw - 32px);box-sizing:border-box;padding:8px 12px;border:1px solid #3b82f6;border-radius:6px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.2)}',
19
+ '.c4b-review .c4b-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px}',
20
+ '.c4b-review .c4b-row small{flex:1;color:#888;white-space:nowrap}',
21
+ '.c4b-review button.c4b-link{padding:0;border:0;background:none;color:#2c3e50;font-weight:700;text-decoration:underline dotted}',
22
+ '.c4b-review ul{margin:6px 0;padding:0;list-style:none;max-height:40vh;overflow:auto}',
23
+ '.c4b-review li{padding:3px 0;border-top:1px solid #eee;overflow-wrap:anywhere}',
24
+ '.c4b-review textarea{margin-top:6px}',
25
+ '.c4b-review .c4b-del{opacity:1}',
12
26
  '.c4b-ctoast{position:fixed;left:16px;bottom:16px;z-index:1003;max-width:420px;padding:8px 14px;border-radius:6px;background:#2c3e50;color:#fff;font:13px/1.4 sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.3)}',
13
27
  '.c4b-ctoast.c4b-err{background:#c0392b}',
14
28
  'mark.c4b-hl{background:#fff3a0;color:inherit;padding:0;border-radius:2px}',
29
+ 'mark.c4b-dhl{background:#cfe3ff;color:inherit;padding:0;border-radius:2px}',
15
30
  'sup.c4b-ref{font-size:.7em;line-height:0;margin-left:1px;user-select:none}',
16
31
  'sup.c4b-ref a{color:#b58900;text-decoration:none;font-weight:700;cursor:pointer}',
17
32
  '.c4b-qrow{transition:background .3s}',
@@ -24,14 +39,19 @@ const CSS = [
24
39
  const jsString = (s) => JSON.stringify(s)
25
40
  .replace(/</g, '\\u003c')
26
41
  .replace(/[\u0080-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
27
- export const commentsSnippet = (heading) => `
42
+ // Начальное состояние агентского режима и ключ проекта вставляет сервер, дальше его обновляют
43
+ // события `c4builder:agent` от сниппета livereload (change site-agent-feedback, design D4, D8).
44
+ export const commentsSnippet = (heading, { agent = false, listening = false, project = '' } = {}) => `
28
45
  <script>
29
46
  (function () {
30
47
  if (window.__c4builderComments) return;
31
48
  window.__c4builderComments = true;
32
49
  var ENDPOINT = '${COMMENTS_PATH}';
50
+ var FEEDBACK = '${FEEDBACK_PATH}';
33
51
  var HEADING = ${jsString(heading)};
34
52
  var TOAST_KEY = '__c4builderCommentToast';
53
+ var DRAFT_KEY = '__c4builderReview:' + ${jsString(project)};
54
+ var AGENT = { on: ${agent}, listening: ${listening} };
35
55
  var BLOCKS = 'p,li,td,th,h1,h2,h3,h4,h5,h6,pre,blockquote,dd,dt,div,section,details,summary';
36
56
  var style = document.createElement('style');
37
57
  style.textContent = ${jsString(CSS)};
@@ -109,7 +129,7 @@ export const commentsSnippet = (heading) => `
109
129
  for (var r = 0; r < roots.length; r++) walk(roots[r]);
110
130
  return { text: chars.join(''), map: map };
111
131
  };
112
- var wrapRange = function (idx, from, to, title) {
132
+ var wrapRange = function (idx, from, to, title, cls) {
113
133
  var first = null, last = null, i;
114
134
  for (i = from; i < to && !first; i++) first = idx.map[i];
115
135
  for (i = to - 1; i >= from && !last; i--) last = idx.map[i];
@@ -125,9 +145,10 @@ export const commentsSnippet = (heading) => `
125
145
  var end = n === last.node ? last.offset + 1 : n.nodeValue.length;
126
146
  if (end < n.nodeValue.length) n.splitText(end);
127
147
  var mid = start > 0 ? n.splitText(start) : n;
128
- if (!mid.nodeValue.trim()) continue;
148
+ // Overlapping quotes: the number link of a question is not part of the text.
149
+ if (!mid.nodeValue.trim() || mid.parentNode.closest('sup.c4b-ref')) continue;
129
150
  var mark = document.createElement('mark');
130
- mark.className = 'c4b-hl';
151
+ mark.className = cls;
131
152
  mark.title = title;
132
153
  mid.parentNode.insertBefore(mark, mid);
133
154
  mark.appendChild(mid);
@@ -135,6 +156,20 @@ export const commentsSnippet = (heading) => `
135
156
  }
136
157
  return marks;
137
158
  };
159
+ var tryIn = function (idx, quote, cls, title) {
160
+ var at = idx.text.indexOf(quote);
161
+ var marks = at < 0 ? null : wrapRange(idx, at, at + quote.length, title, cls);
162
+ return marks && marks.length ? marks : null;
163
+ };
164
+ // The quote is searched in its own section first: the same words may repeat on the page.
165
+ var locate = function (root, q, skip, cls, title) {
166
+ var marks = null;
167
+ if (q.section) {
168
+ var sh = findHeading(root, q.section);
169
+ if (sh && skip.indexOf(sh) < 0) marks = tryIn(textIndex(sectionOf(sh), skip), q.quote, cls, title);
170
+ }
171
+ return marks || tryIn(textIndex([root], skip), q.quote, cls, title);
172
+ };
138
173
  var flash = function (row) {
139
174
  row.scrollIntoView({ block: 'center', behavior: 'smooth' });
140
175
  row.classList.add('c4b-qflash');
@@ -185,11 +220,6 @@ export const commentsSnippet = (heading) => `
185
220
  item.cell.appendChild(del);
186
221
  };
187
222
  var skip = [h].concat(section);
188
- var tryIn = function (idx, q) {
189
- var at = idx.text.indexOf(q.quote);
190
- var marks = at < 0 ? null : wrapRange(idx, at, at + q.quote.length, q.comment);
191
- return marks && marks.length ? marks : null;
192
- };
193
223
  items.forEach(function (item) {
194
224
  item.row.classList.add('c4b-qrow');
195
225
  item.row.id = item.row.id || 'c4b-q-' + item.number;
@@ -197,11 +227,7 @@ export const commentsSnippet = (heading) => `
197
227
  // Index is rebuilt per try: wrapping splits text nodes and stales the previous map.
198
228
  var q = null, marks = null;
199
229
  parseQuestions(item.text).some(function (c) {
200
- if (c.section) {
201
- var sh = findHeading(root, c.section);
202
- if (sh && skip.indexOf(sh) < 0) marks = tryIn(textIndex(sectionOf(sh), skip), c);
203
- }
204
- marks = marks || tryIn(textIndex([root], skip), c);
230
+ marks = locate(root, c, skip, 'c4b-hl', c.comment);
205
231
  q = marks ? c : null;
206
232
  return !!marks;
207
233
  });
@@ -219,7 +245,12 @@ export const commentsSnippet = (heading) => `
219
245
  lastMark.parentNode.insertBefore(sup, lastMark.nextSibling);
220
246
  });
221
247
  };
222
- var schedule = function () { setTimeout(function () { try { highlight(); } catch (e) {} }, 50); };
248
+ var schedule = function () {
249
+ setTimeout(function () {
250
+ try { highlight(); } catch (e) {}
251
+ try { highlightDraft(); } catch (e) {}
252
+ }, 50);
253
+ };
223
254
  var docsifyVm = null;
224
255
  if (window.$docsify) {
225
256
  window.$docsify.plugins = [].concat(window.$docsify.plugins || [], function (hook, vm) {
@@ -230,12 +261,24 @@ export const commentsSnippet = (heading) => `
230
261
  window.addEventListener('hashchange', function () { setTimeout(schedule, 300); });
231
262
  window.addEventListener('load', function () { setTimeout(schedule, 500); });
232
263
 
233
- var btn = document.createElement('button');
234
- btn.type = 'button';
235
- btn.className = 'c4b-cbtn';
236
- btn.textContent = 'Comment';
237
- btn.hidden = true;
238
- document.body.appendChild(btn);
264
+ var button = function (label, onClick, cls) {
265
+ var b = document.createElement('button');
266
+ b.type = 'button';
267
+ b.textContent = label;
268
+ if (cls) b.className = cls;
269
+ if (onClick) b.addEventListener('click', onClick);
270
+ return b;
271
+ };
272
+ var btns = document.createElement('div');
273
+ btns.className = 'c4b-cbtns';
274
+ btns.hidden = true;
275
+ var fbBtn = button('Feedback', null, 'c4b-fb');
276
+ fbBtn.title = 'Tell the agent what to change';
277
+ var qBtn = button('Question');
278
+ qBtn.title = 'Add a question to the ' + HEADING + ' section of the source file';
279
+ btns.appendChild(fbBtn);
280
+ btns.appendChild(qBtn);
281
+ document.body.appendChild(btns);
239
282
  var pop = null;
240
283
 
241
284
  var selection = function () {
@@ -273,9 +316,9 @@ export const commentsSnippet = (heading) => `
273
316
  var update = function () {
274
317
  if (pop) return;
275
318
  var s = selection();
276
- if (!s) { btn.hidden = true; return; }
277
- btn.hidden = false;
278
- place(btn, anchorRect(s.range));
319
+ if (!s) { btns.hidden = true; return; }
320
+ btns.hidden = false;
321
+ place(btns, anchorRect(s.range));
279
322
  };
280
323
  document.addEventListener('selectionchange', function () { setTimeout(update, 0); });
281
324
 
@@ -292,8 +335,8 @@ export const commentsSnippet = (heading) => `
292
335
  if (!p) return (window.$docsify && window.$docsify.homepage) || 'README.md';
293
336
  return /\\.md$/i.test(p) ? p : p + '.md';
294
337
  };
295
- var send = function (body, method) {
296
- return fetch(ENDPOINT, {
338
+ var send = function (body, method, url) {
339
+ return fetch(url || ENDPOINT, {
297
340
  method: method || 'POST',
298
341
  headers: { 'Content-Type': 'application/json' },
299
342
  body: JSON.stringify(body)
@@ -304,75 +347,239 @@ export const commentsSnippet = (heading) => `
304
347
  });
305
348
  });
306
349
  };
350
+ var failure = function (what) {
351
+ return function (err) { toast(what + ' failed: ' + (err && err.message ? err.message : err), true); };
352
+ };
353
+
354
+ // Feedback to the agent: "Delivered" only after the server handed the batch to
355
+ // "c4builder feedback"; the event may outrun the POST response, hence both maps.
356
+ var delivered = {}, pending = {};
357
+ var sendBatch = function (decision, comments, general) {
358
+ return send({ decision: decision, comments: comments, general: general }, 'POST', FEEDBACK).then(function (r) {
359
+ if (delivered[r.id]) return toast('Delivered to the agent');
360
+ pending[r.id] = true;
361
+ toast(r.queued ? 'Queued \\u2014 agent is not listening' : 'Sent to the agent');
362
+ });
363
+ };
364
+ window.addEventListener('c4builder:delivered', function (e) {
365
+ var id = e.detail && e.detail.id;
366
+ delivered[id] = true;
367
+ if (pending[id]) { delete pending[id]; toast('Delivered to the agent'); }
368
+ });
369
+
370
+ // Review draft lives in localStorage per project: it survives livereload and a closed tab;
371
+ // without storage it lives until the page reloads.
372
+ var memDraft = [];
373
+ var draft = function () {
374
+ try {
375
+ var v = JSON.parse(localStorage.getItem(DRAFT_KEY) || '[]');
376
+ return Array.isArray(v) ? v : [];
377
+ } catch (e) { return memDraft; }
378
+ };
379
+ var saveDraft = function (items) {
380
+ memDraft = items;
381
+ try {
382
+ if (items.length) localStorage.setItem(DRAFT_KEY, JSON.stringify(items));
383
+ else localStorage.removeItem(DRAFT_KEY);
384
+ } catch (e) {}
385
+ renderPanel();
386
+ highlightDraft();
387
+ };
388
+ var highlightDraft = function () {
389
+ var root = content();
390
+ if (!root) return;
391
+ var marks = root.querySelectorAll('mark.c4b-dhl');
392
+ for (var i = 0; i < marks.length; i++) {
393
+ var m = marks[i], parent = m.parentNode;
394
+ while (m.firstChild) parent.insertBefore(m.firstChild, m);
395
+ parent.removeChild(m);
396
+ parent.normalize();
397
+ }
398
+ var page = pageFile();
399
+ var h = findHeading(root, HEADING);
400
+ var skip = h ? [h].concat(sectionOf(h)) : [];
401
+ draft().forEach(function (it) {
402
+ if (it.page === page) locate(root, { quote: collapse(it.quote), section: it.section }, skip, 'c4b-dhl', it.comment);
403
+ });
404
+ };
405
+
406
+ var panel = document.createElement('div');
407
+ panel.className = 'c4b-review';
408
+ var expanded = false, busy = false;
409
+ var count = button('', function () { expanded = !expanded; renderPanel(); }, 'c4b-link');
410
+ var listen = document.createElement('small');
411
+ listen.className = 'c4b-listen';
412
+ var list = document.createElement('ul');
413
+ var general = document.createElement('textarea');
414
+ general.placeholder = 'Overall comment (optional, Ctrl+Enter to submit, Esc to hide)';
415
+ general.hidden = true;
416
+ var finish = function (decision) {
417
+ var items = draft(), ids = items.map(function (it) { return it.id; });
418
+ busy = true;
419
+ renderPanel();
420
+ sendBatch(decision, items, general.hidden ? '' : general.value.trim()).then(function () {
421
+ general.value = '';
422
+ general.hidden = true;
423
+ expanded = false;
424
+ saveDraft(draft().filter(function (it) { return ids.indexOf(it.id) < 0; }));
425
+ }, failure('Review')).then(function () { busy = false; renderPanel(); });
426
+ };
427
+ var discard = button('Discard', function () {
428
+ var n = draft().length;
429
+ if (n && !window.confirm('Discard ' + n + ' pending comment' + (n === 1 ? '' : 's') + '?')) return;
430
+ general.value = '';
431
+ general.hidden = true;
432
+ saveDraft([]);
433
+ });
434
+ var approve = button('Approve', function () { finish('approved'); });
435
+ approve.title = 'Tell the agent there is nothing more to change (pending comments go along)';
436
+ var submit = button('Submit review', function () {
437
+ if (!general.hidden) return finish('submitted');
438
+ general.hidden = false;
439
+ general.focus();
440
+ }, 'c4b-primary');
441
+ general.addEventListener('keydown', function (e) {
442
+ if (e.key === 'Escape') { e.preventDefault(); general.hidden = true; }
443
+ else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submit.click(); }
444
+ });
445
+ var head = document.createElement('div');
446
+ head.className = 'c4b-row';
447
+ [count, listen, discard, approve, submit].forEach(function (el) { head.appendChild(el); });
448
+ panel.appendChild(head);
449
+ panel.appendChild(list);
450
+ panel.appendChild(general);
451
+ document.body.appendChild(panel);
452
+ var renderPanel = function () {
453
+ panel.hidden = !AGENT.on;
454
+ var items = draft(), pages = {};
455
+ items.forEach(function (it) { pages[it.page] = true; });
456
+ var n = Object.keys(pages).length;
457
+ count.textContent = items.length
458
+ ? 'Review: ' + items.length + ' pending (' + n + (n === 1 ? ' page)' : ' pages)')
459
+ : 'Review: nothing pending';
460
+ count.disabled = !items.length;
461
+ list.textContent = '';
462
+ items.forEach(function (it) {
463
+ var li = document.createElement('li');
464
+ var q = collapse(it.quote);
465
+ li.textContent = it.page + ' \\u2014 \\u00ab' + (q.length > 60 ? q.slice(0, 59) + '\\u2026' : q) + '\\u00bb: ' + it.comment;
466
+ var del = button('\\u00d7', function () {
467
+ saveDraft(draft().filter(function (x) { return x.id !== it.id; }));
468
+ }, 'c4b-del');
469
+ del.title = 'Remove from the review';
470
+ li.appendChild(del);
471
+ list.appendChild(li);
472
+ });
473
+ list.hidden = !expanded || !items.length;
474
+ discard.disabled = busy;
475
+ approve.disabled = busy;
476
+ submit.disabled = busy || !items.length;
477
+ };
478
+ var renderAgent = function () {
479
+ fbBtn.hidden = !AGENT.on;
480
+ renderPanel();
481
+ var text = 'agent: ' + (AGENT.listening ? 'listening \\u25cf' : 'not listening \\u25cb');
482
+ var els = document.querySelectorAll('.c4b-listen');
483
+ for (var i = 0; i < els.length; i++) { els[i].textContent = text; els[i].title = text; }
484
+ };
485
+ window.addEventListener('c4builder:agent', function (e) {
486
+ var d = e.detail || {};
487
+ AGENT.on = !!d.on;
488
+ AGENT.listening = !!d.listening;
489
+ renderAgent();
490
+ });
491
+ window.addEventListener('storage', function (e) {
492
+ if (e.key === DRAFT_KEY) { renderPanel(); highlightDraft(); }
493
+ });
307
494
 
308
495
  var close = function () {
309
496
  if (pop) { pop.remove(); pop = null; }
310
- btn.hidden = true;
497
+ btns.hidden = true;
311
498
  };
312
- var open = function (data, rect) {
499
+ var open = function (data, rect, feedback) {
313
500
  close();
314
501
  pop = document.createElement('div');
315
502
  pop.className = 'c4b-cpop';
316
503
  var quote = document.createElement('blockquote');
317
504
  quote.textContent = data.quote;
318
505
  var ta = document.createElement('textarea');
319
- ta.placeholder = 'Your question or comment (Ctrl+Enter to add, Esc to cancel)';
506
+ ta.placeholder = (feedback ? 'What should the agent change?' : 'Your question') + ' (Ctrl+Enter to send, Esc to cancel)';
320
507
  var row = document.createElement('div');
321
508
  row.className = 'c4b-row';
322
509
  var hint = document.createElement('small');
323
- hint.textContent = 'To: ' + HEADING + (data.section ? ' / ' + data.section : '');
324
- hint.title = hint.textContent;
510
+ if (feedback) hint.className = 'c4b-listen';
511
+ else hint.textContent = hint.title = 'To: ' + HEADING + (data.section ? ' / ' + data.section : '');
325
512
  var actions = document.createElement('span');
326
- var cancel = document.createElement('button');
327
- cancel.type = 'button';
328
- cancel.textContent = 'Cancel';
329
- var ok = document.createElement('button');
330
- ok.type = 'button';
331
- ok.className = 'c4b-primary';
332
- ok.textContent = 'Add';
333
- actions.appendChild(cancel);
334
- actions.appendChild(document.createTextNode(' '));
335
- actions.appendChild(ok);
513
+ var buttons = [button('Cancel', close)];
514
+ var run = function (action) {
515
+ return function () {
516
+ var comment = ta.value.trim();
517
+ if (!comment) { ta.focus(); return; }
518
+ buttons.forEach(function (b) { b.disabled = true; });
519
+ action(comment).then(close, function (err) {
520
+ buttons.forEach(function (b) { b.disabled = false; });
521
+ failure(feedback ? 'Feedback' : 'Question')(err);
522
+ });
523
+ };
524
+ };
525
+ var item = function (comment) {
526
+ return { page: pageFile(), quote: data.quote, section: data.section, comment: comment };
527
+ };
528
+ var primary;
529
+ if (feedback) {
530
+ var add = button('Add to review', run(function (c) {
531
+ var it = item(c);
532
+ it.id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
533
+ saveDraft(draft().concat([it]));
534
+ return Promise.resolve();
535
+ }));
536
+ var now = button('Send now', run(function (c) { return sendBatch('submitted', [item(c)], ''); }));
537
+ buttons.push(add, now);
538
+ // As in a GitLab merge request: once a review is started, comments go into it.
539
+ primary = draft().length ? add : now;
540
+ } else {
541
+ primary = button('Add question', run(function (c) {
542
+ return send(item(c)).then(function (r) {
543
+ var msg = 'Added to ' + r.file + (r.number ? ' as #' + r.number : '');
544
+ try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
545
+ toast(msg);
546
+ });
547
+ }));
548
+ buttons.push(primary);
549
+ }
550
+ primary.className = 'c4b-primary';
551
+ buttons.forEach(function (b) {
552
+ actions.appendChild(b);
553
+ actions.appendChild(document.createTextNode(' '));
554
+ });
336
555
  row.appendChild(hint);
337
556
  row.appendChild(actions);
338
557
  pop.appendChild(quote);
339
558
  pop.appendChild(ta);
340
559
  pop.appendChild(row);
341
560
  document.body.appendChild(pop);
561
+ renderAgent();
342
562
  place(pop, rect);
343
563
  ta.focus();
344
- var submit = function () {
345
- var comment = ta.value.trim();
346
- if (!comment) { ta.focus(); return; }
347
- ok.disabled = true;
348
- cancel.disabled = true;
349
- send({ page: pageFile(), quote: data.quote, section: data.section, comment: comment }).then(function (r) {
350
- var msg = 'Added to ' + r.file + (r.number ? ' as #' + r.number : '');
351
- try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
352
- close();
353
- toast(msg);
354
- }, function (err) {
355
- ok.disabled = false;
356
- cancel.disabled = false;
357
- toast('Comment failed: ' + (err && err.message ? err.message : err), true);
358
- });
359
- };
360
- ok.addEventListener('click', submit);
361
- cancel.addEventListener('click', close);
362
564
  ta.addEventListener('keydown', function (e) {
363
565
  if (e.key === 'Escape') { e.preventDefault(); close(); }
364
- else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submit(); }
566
+ else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); primary.click(); }
365
567
  });
366
568
  };
367
569
 
368
- btn.addEventListener('mousedown', function (e) { e.preventDefault(); });
369
- btn.addEventListener('click', function () {
370
- var s = selection();
371
- if (s) open({ quote: s.text, section: headingBefore(s.range) }, anchorRect(s.range));
372
- });
570
+ var openFor = function (feedback) {
571
+ return function () {
572
+ var s = selection();
573
+ if (s) open({ quote: s.text, section: headingBefore(s.range) }, anchorRect(s.range), feedback);
574
+ };
575
+ };
576
+ btns.addEventListener('mousedown', function (e) { e.preventDefault(); });
577
+ fbBtn.addEventListener('click', openFor(true));
578
+ qBtn.addEventListener('click', openFor(false));
373
579
  document.addEventListener('mousedown', function (e) {
374
- if (pop && !pop.contains(e.target) && e.target !== btn) close();
580
+ if (pop && !pop.contains(e.target) && !btns.contains(e.target)) close();
375
581
  });
376
582
  window.addEventListener('hashchange', close);
583
+ renderAgent();
377
584
  })();
378
585
  </script>`;
@@ -1,17 +1,21 @@
1
1
  import chalk from 'chalk';
2
+ import { createHash, randomUUID } from 'node:crypto';
2
3
  import path from 'node:path';
3
4
  import fs from 'node:fs';
4
5
  import express from 'express';
5
6
  import open from 'open';
7
+ import { batchSchema, formatBatch } from "../../core/feedback.js";
6
8
  import { deleteQuestion, formatQuestion, pickSource, writeQuestion } from "../../core/questions.js";
7
9
  import { packageJson as pkg } from "../../util/paths.js";
8
10
  import { pendingUpdate } from "../update.js";
9
- import { COMMENTS_PATH, commentsSnippet } from "./site.comments.js";
11
+ import { COMMENTS_PATH, commentsSnippet, FEEDBACK_HEADER, FEEDBACK_PATH } from "./site.comments.js";
10
12
  const COMMENT_MAX = 2000;
11
13
  const LIVERELOAD_PATH = '/__livereload';
12
14
  // Восстановление скролла после reload: docsify рендерит контент асинхронно после fetch md,
13
15
  // высота страницы доступна не сразу — поллим до достижения нужной y или таймаута.
14
16
  // Комментарии держим здесь, а не в сниппете: он уходит в браузер пользователя.
17
+ // События агента (design D4) идут по тому же потоку и пересылаются сниппету комментариев
18
+ // DOM-событиями `c4builder:*` — второй EventSource странице не нужен.
15
19
  const LIVERELOAD_SNIPPET = `
16
20
  <script>
17
21
  (function () {
@@ -48,6 +52,13 @@ const LIVERELOAD_SNIPPET = `
48
52
  es.close();
49
53
  location.reload();
50
54
  });
55
+ ['agent', 'delivered'].forEach(function (name) {
56
+ es.addEventListener(name, function (e) {
57
+ try {
58
+ window.dispatchEvent(new CustomEvent('c4builder:' + name, { detail: JSON.parse(e.data) }));
59
+ } catch (err) {}
60
+ });
61
+ });
51
62
  es.onerror = function () {
52
63
  es.close();
53
64
  setTimeout(connect, delay);
@@ -88,12 +99,24 @@ pages = () => new Map()) => {
88
99
  const port = program.port || currentConfiguration.WEB_PORT;
89
100
  const distFolder = path.resolve(currentConfiguration.DIST_FOLDER);
90
101
  const liveReloadEnabled = !!(program.watch && reloadEmitter);
102
+ const agent = { ...(program.agent ?? { on: false, name: '', explicit: false }) };
91
103
  // Кеш index.html — отдаём его из памяти, если на диске его временно нет
92
104
  // (в окне build.js → emptyDir(DIST_FOLDER) перед записью).
93
105
  let lastIndexHtml = null;
94
106
  // reloadEmitter в условии — сужает тип до EventEmitter внутри блока (liveReloadEnabled
95
107
  // истинно ⟺ reloadEmitter задан), поэтому дальше без non-null assertions.
96
108
  if (liveReloadEnabled && reloadEmitter) {
109
+ // Состояние слушателя `c4builder feedback` (design D4): подключён, пока открыт long-poll
110
+ // или с конца последнего прошло меньше idleMs — зазор между повторами команды.
111
+ let listening = false;
112
+ const agentState = () => ({ on: agent.on, listening });
113
+ const setAgent = (on, listen) => {
114
+ if (on === agent.on && listen === listening)
115
+ return;
116
+ agent.on = on;
117
+ listening = listen;
118
+ reloadEmitter.emit('agent', agentState());
119
+ };
97
120
  app.get(LIVERELOAD_PATH, (req, res) => {
98
121
  res.set({
99
122
  'Content-Type': 'text/event-stream',
@@ -104,21 +127,35 @@ pages = () => new Map()) => {
104
127
  res.flushHeaders();
105
128
  res.write('retry: 1000\n\n');
106
129
  const onReload = () => res.write('event: reload\ndata: {}\n\n');
130
+ const onAgent = (data) => res.write(`event: agent\ndata: ${JSON.stringify(data)}\n\n`);
131
+ const onDelivered = (data) => res.write(`event: delivered\ndata: ${JSON.stringify(data)}\n\n`);
107
132
  const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);
108
133
  reloadEmitter.on('reload', onReload); // liveReloadEnabled ⇒ reloadEmitter задан
134
+ reloadEmitter.on('agent', onAgent);
135
+ reloadEmitter.on('delivered', onDelivered);
136
+ onAgent(agentState());
109
137
  console.log(chalk.gray(`livereload: client connected (${reloadEmitter.listenerCount('reload')} total)`));
110
138
  req.on('close', () => {
111
139
  clearInterval(heartbeat);
112
140
  reloadEmitter.off('reload', onReload);
141
+ reloadEmitter.off('agent', onAgent);
142
+ reloadEmitter.off('delivered', onDelivered);
113
143
  console.log(chalk.gray(`livereload: client disconnected (${reloadEmitter.listenerCount('reload')} left)`));
114
144
  });
115
145
  });
146
+ // Ключ черновиков review в localStorage (design D5): страницы разных проектов на одном
147
+ // localhost:<port> не должны видеть чужой черновик.
148
+ const projectKey = createHash('sha1').update(process.cwd()).digest('hex').slice(0, 8);
116
149
  // Кэш проверки обновлений читается на каждую страницу: результат фоновой проверки,
117
150
  // запущенной этим же `site -w`, виден со следующей перезагрузки без рестарта сервера.
118
151
  const inject = (html) => {
119
152
  const next = pendingUpdate();
120
153
  const snippet = LIVERELOAD_SNIPPET +
121
- commentsSnippet(currentConfiguration.QUESTIONS_HEADING) +
154
+ commentsSnippet(currentConfiguration.QUESTIONS_HEADING, {
155
+ agent: agent.on,
156
+ listening,
157
+ project: projectKey
158
+ }) +
122
159
  (next ? updateBanner(next) : '');
123
160
  if (html.includes('</body>'))
124
161
  return html.replace('</body>', `${snippet}</body>`);
@@ -129,15 +166,21 @@ pages = () => new Map()) => {
129
166
  // любого сайта в браузере может слать POST на localhost — требуем Origin самого сайта.
130
167
  const str = (v) => (typeof v === 'string' ? v : '');
131
168
  const heading = currentConfiguration.QUESTIONS_HEADING;
169
+ // Host — только локальный: при DNS rebinding чужой домен резолвится в 127.0.0.1 и
170
+ // присылает согласованные Host и Origin, сверка Origin с одним Host это пропускает.
171
+ const localHost = (req) => {
172
+ const host = req.get('host');
173
+ const local = [`127.0.0.1:${req.socket.localPort}`, `localhost:${req.socket.localPort}`];
174
+ return host && local.includes(host) ? host : undefined;
175
+ };
176
+ const fromSite = (req) => {
177
+ const host = localHost(req);
178
+ return !!host && req.get('origin') === `http://${host}`;
179
+ };
132
180
  // Общая часть POST/DELETE: проверка Origin и исходный файл страницы из тела запроса.
133
181
  // Ошибка уже отправлена клиенту, когда возвращается undefined.
134
182
  const resolveTarget = (req, res) => {
135
- const origin = req.get('origin');
136
- const host = req.get('host');
137
- // Host — только локальный: при DNS rebinding чужой домен резолвится в 127.0.0.1 и
138
- // присылает согласованные Host и Origin, сверка Origin с одним Host это пропускает.
139
- const local = [`127.0.0.1:${req.socket.localPort}`, `localhost:${req.socket.localPort}`];
140
- if (!origin || !host || !local.includes(host) || origin !== `http://${host}`) {
183
+ if (!fromSite(req)) {
141
184
  res.status(403).json({ error: 'comments are accepted only from the site itself' });
142
185
  return undefined;
143
186
  }
@@ -214,6 +257,104 @@ pages = () => new Map()) => {
214
257
  res.status(500).json({ error: `could not remove the comment: ${e.message}` });
215
258
  }
216
259
  });
260
+ // Замечания агенту (change site-agent-feedback): FIFO пачек в памяти (design D3), страница
261
+ // шлёт POST, `c4builder feedback` забирает long-poll'ом (D1). Пачка снимается с очереди,
262
+ // когда ответ записан; обрыв до этого возвращает её в голову очереди.
263
+ const { pollMs = 25_000, idleMs = 30_000 } = program;
264
+ const queue = [];
265
+ const waiters = [];
266
+ let polls = 0;
267
+ let idleTimer;
268
+ const disabled = () => agent.explicit && !agent.on;
269
+ const drop = (res) => {
270
+ const i = waiters.indexOf(res);
271
+ if (i >= 0)
272
+ waiters.splice(i, 1);
273
+ };
274
+ const pump = () => {
275
+ while (queue.length && waiters.length) {
276
+ const res = waiters.shift();
277
+ const item = queue.shift();
278
+ if (res && item)
279
+ deliver(res, item);
280
+ }
281
+ };
282
+ const deliver = (res, item) => {
283
+ res.on('close', () => {
284
+ if (!res.writableFinished) {
285
+ queue.unshift(item);
286
+ pump();
287
+ return;
288
+ }
289
+ console.log(chalk.gray(`feedback: batch delivered to the agent (${item.batch.decision})`));
290
+ reloadEmitter.emit('delivered', { id: item.id });
291
+ });
292
+ res.type('text/markdown').send(formatBatch(item.batch, pages(), process.cwd()));
293
+ };
294
+ app.post(FEEDBACK_PATH, express.json({ limit: '4mb' }), (req, res) => {
295
+ if (!fromSite(req)) {
296
+ res.status(403).json({ error: 'feedback is accepted only from the site itself' });
297
+ return;
298
+ }
299
+ if (disabled()) {
300
+ res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
301
+ return;
302
+ }
303
+ const parsed = batchSchema.safeParse(req.body);
304
+ if (!parsed.success) {
305
+ const issue = parsed.error.issues[0];
306
+ res.status(400).json({
307
+ error: `invalid feedback: ${issue?.path.join('.')} ${issue?.message}`
308
+ });
309
+ return;
310
+ }
311
+ const batch = parsed.data;
312
+ if (batch.decision === 'submitted' && !batch.comments.length && !batch.general) {
313
+ res.status(400).json({ error: 'nothing to send' });
314
+ return;
315
+ }
316
+ const id = randomUUID();
317
+ const queued = !waiters.length;
318
+ queue.push({ id, batch });
319
+ pump();
320
+ console.log(chalk.gray(`feedback: ${batch.decision}, ${batch.comments.length} comment(s)${queued ? ' — queued, no agent listening' : ''}`));
321
+ res.json({ id, queued });
322
+ });
323
+ // Нестандартный заголовок вынуждает браузер к preflight, на который сервер не отвечает:
324
+ // чужая страница не может ни прочитать, ни «съесть» очередь простым запросом (design D6).
325
+ app.get(`${FEEDBACK_PATH}/next`, (req, res) => {
326
+ if (!localHost(req) || req.get('origin') || req.get(FEEDBACK_HEADER) !== '1') {
327
+ res.status(403).json({ error: 'feedback is served only to the c4builder feedback command' });
328
+ return;
329
+ }
330
+ if (disabled()) {
331
+ res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
332
+ return;
333
+ }
334
+ if (!agent.on)
335
+ console.log(chalk.gray('agent mode on: "c4builder feedback" connected'));
336
+ polls++;
337
+ clearTimeout(idleTimer);
338
+ setAgent(true, true);
339
+ let timer;
340
+ res.on('close', () => {
341
+ clearTimeout(timer);
342
+ drop(res);
343
+ if (--polls === 0)
344
+ idleTimer = setTimeout(() => setAgent(agent.on, false), idleMs);
345
+ });
346
+ waiters.push(res);
347
+ pump();
348
+ if (!waiters.includes(res))
349
+ return;
350
+ timer = setTimeout(() => {
351
+ // Пачку успели отдать этому запросу — ответ уже пишется, 204 не нужен.
352
+ if (!waiters.includes(res))
353
+ return;
354
+ drop(res);
355
+ res.status(204).end();
356
+ }, req.query.wait === '0' ? 0 : pollMs);
357
+ });
217
358
  app.use((req, res, next) => {
218
359
  if (req.method !== 'GET')
219
360
  return next();
@@ -264,6 +405,8 @@ pages = () => new Map()) => {
264
405
  console.log(`go to ${chalk.green(url)}`);
265
406
  if (liveReloadEnabled)
266
407
  console.log(chalk.gray('livereload enabled (browser auto-reload on rebuild)'));
408
+ if (liveReloadEnabled && agent.on)
409
+ console.log(`agent mode (${agent.name}): page feedback goes to "c4builder feedback --wait${program.port ? ` -p ${port}` : ''}" — run it in the background and again after each batch`);
267
410
  // --open: открываем браузер один раз при старте; BROWSER=none отключает (CI/headless)
268
411
  if (program.open && process.env.BROWSER !== 'none')
269
412
  open(url).catch(() => console.log(chalk.gray('could not open the browser automatically')));
@@ -9,6 +9,8 @@ import cmdJre from "./commands/jre.js";
9
9
  import cmdCheck from "./commands/check.js";
10
10
  import cmdList from "./commands/list.js";
11
11
  import cmdSite from "./commands/site.js";
12
+ import cmdFeedback from "./commands/feedback.js";
13
+ import { detectAgent } from "./agent.js";
12
14
  import { cmdUpdate, pendingUpdate, startUpdateCheck } from "./update.js";
13
15
  import cmdCollect from "./wizard/collect.js";
14
16
  import { build } from "../core/build.js";
@@ -27,7 +29,7 @@ import { packageJson as pkg } from "../util/paths.js";
27
29
  import { outputDirs, parseConfig } from "../config/options.js";
28
30
  import { isValidPort } from "../config/schema.js";
29
31
  // Позиционные подкоманды: allowExcessArguments пропускает любые, список — единственный фильтр.
30
- const COMMANDS = ['check', 'jre', 'site', 'new', 'config', 'update'];
32
+ const COMMANDS = ['check', 'jre', 'site', 'new', 'config', 'update', 'feedback'];
31
33
  const intro = () => {
32
34
  console.log(chalk.blue(figlet.textSync('c4builder')));
33
35
  console.log(chalk.gray('Blow up your software documentation writing skills'));
@@ -120,6 +122,9 @@ export default async () => {
120
122
  .option('--site', 'serve the generated site')
121
123
  .option('-w, --watch', 'watch for changes and rebuild')
122
124
  .option('-o, --open', 'open the generated site in the browser (with --site)')
125
+ .option('--agent', 'with --site -w: accept page feedback for a coding agent (c4builder feedback)')
126
+ .option('--no-agent', 'with --site -w: no agent feedback even when run by a coding agent')
127
+ .option('--wait', 'for `feedback`: wait for the next batch of page feedback')
123
128
  .option('--docs', 'a brief explanation for the available configuration options')
124
129
  .option('--system-fonts', 'render with system fonts instead of the bundled one (not deterministic)')
125
130
  .option('-p, --port <n>', 'port used for serving the generated site', (v) => {
@@ -140,8 +145,8 @@ export default async () => {
140
145
  process.exit(1);
141
146
  }
142
147
  // --version/--help уже вышли в parse. Уведомление — по кэшу прошлой проверки, до её
143
- // перезапуска; exit покрывает и выходы через process.exit.
144
- if (cmd !== 'update') {
148
+ // перезапуска; exit покрывает и выходы через process.exit. Вывод `feedback` читает агент.
149
+ if (cmd !== 'update' && cmd !== 'feedback') {
145
150
  const next = pendingUpdate();
146
151
  if (next)
147
152
  process.once('exit', () => process.stderr.write(`c4builder ${next} is available (current ${pkg.version}). Run: c4builder update\n`));
@@ -156,6 +161,9 @@ export default async () => {
156
161
  // Обновление CLI — до Configstore, чтобы не создавать .c4builder в любом каталоге.
157
162
  if (cmd === 'update')
158
163
  return cmdUpdate();
164
+ // Замечания со страницы `site -w` — тоже до Configstore: команду запускают в любом каталоге.
165
+ if (cmd === 'feedback')
166
+ return cmdFeedback(path.join(process.cwd(), opts.configFile ?? '.c4builder'), opts);
159
167
  // `site`/`new`/`config` — позиционные синонимы одноимённых флагов: эту форму печатают
160
168
  // подсказки CLI и учат README, шаблон и скиллы.
161
169
  if (cmd)
@@ -348,7 +356,7 @@ export default async () => {
348
356
  }
349
357
  isBuilding = false;
350
358
  if (opts.site)
351
- return await cmdSite(options, opts, reloadEmitter, () => pages);
359
+ return await cmdSite(options, { ...opts, agent: detectAgent(process.env, opts) }, reloadEmitter, () => pages);
352
360
  if (options.GENERATE_WEBSITE && !opts.watch) {
353
361
  console.log(chalk.gray('\nto view the generated website run'));
354
362
  console.log(`> c4builder site`);
@@ -0,0 +1,59 @@
1
+ // Пачка замечаний агенту со страницы `site -w` (change site-agent-feedback, design D6–D7):
2
+ // схема тела запроса страницы и markdown, который печатает `c4builder feedback`.
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ const TEXT_MAX = 2000;
6
+ const text = z.string().max(TEXT_MAX);
7
+ const collapse = (s) => s.replace(/\s+/g, ' ').trim();
8
+ // Цитата — только ориентир для агента: длинное выделение обрезается, а не валит всю пачку.
9
+ const clip = (s) => {
10
+ const q = collapse(s);
11
+ return q.length > TEXT_MAX ? `${q.slice(0, TEXT_MAX - 1).trimEnd()}…` : q;
12
+ };
13
+ export const batchSchema = z.object({
14
+ decision: z.enum(['submitted', 'approved']),
15
+ comments: z
16
+ .array(z.object({
17
+ // Ключ карты «страница → исходники»: тот же вид, что у вопросов (без ведущего «/»).
18
+ page: z
19
+ .string()
20
+ .max(1000)
21
+ .transform((p) => path.posix.normalize(p.replace(/^\/+/, ''))),
22
+ quote: z.string().transform(clip),
23
+ section: text.default(''),
24
+ comment: text.trim().min(1)
25
+ }))
26
+ .max(200),
27
+ general: text.trim().default('')
28
+ });
29
+ const quoted = (s) => s
30
+ .split('\n')
31
+ .map((l) => (l.trim() ? `> ${l}` : '>'))
32
+ .join('\n');
33
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
34
+ // Исходники — относительно cwd: агент правит их из каталога проекта.
35
+ export const formatBatch = (batch, pages, cwd) => {
36
+ const { comments, general } = batch;
37
+ const pageCount = new Set(comments.map((c) => c.page)).size;
38
+ const out = [
39
+ '# Page feedback (c4builder)',
40
+ '',
41
+ `Decision: ${batch.decision}`,
42
+ `Comments: ${comments.length}${comments.length ? ` on ${plural(pageCount, 'page')}` : ''}`
43
+ ];
44
+ comments.forEach((c, i) => {
45
+ const section = collapse(c.section);
46
+ const sources = (pages.get(c.page) ?? []).map((s) => path.relative(cwd, s).split(path.sep).join('/'));
47
+ // Страница схлопывается только в заголовке: перевод строки в ней сломал бы формат пачки,
48
+ // а ключ карты исходников остаётся как есть.
49
+ out.push('', `## ${i + 1}. ${collapse(c.page)}${section ? ` (§ ${section})` : ''}`);
50
+ if (sources.length)
51
+ out.push(`Sources: ${sources.join(', ')}`);
52
+ if (c.quote)
53
+ out.push(`Quote: "${c.quote}"`);
54
+ out.push('', quoted(c.comment));
55
+ });
56
+ if (general)
57
+ out.push('', '## General', '', quoted(general));
58
+ return `${out.join('\n')}\n`;
59
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfacapital-tech/c4builder",
3
- "version": "0.9.0-rc.1",
3
+ "version": "0.9.0-rc.2",
4
4
  "description": "A CLI tool designed to compile a folder structure of markdowns and plant uml files into a site, single file markdown or a collection of markdowns with links",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,6 +9,7 @@ C4 diagrams (PlantUML, D2), BPMN processes and markdown in `src/` are compiled i
9
9
  c4builder # build docs/ (non-interactive with a complete .c4builder)
10
10
  c4builder check <file...> # validate .puml / .iuml / .d2 / .bpmn files, exit code 0 = OK
11
11
  c4builder --site -w # live preview on http://localhost:3000 - never exits
12
+ c4builder feedback --wait # wait for the next batch of comments from the preview page
12
13
  ```
13
14
 
14
15
  Not installed: `npm i -g @alfacapital-tech/c4builder` (Node.js 22.17+; Java 17+ is used
@@ -22,7 +23,12 @@ updates it.
22
23
  look like `✗ <file>: line <N>: <message>` (BPMN: `✗ <file>: <element id> [<rule>] <message>`).
23
24
  Fix until the exit code is 0.
24
25
  3. Run `c4builder` and make sure it exits with code 0. Leave `c4builder --site -w` to a
25
- human or run it in the background.
26
+ human or run it in the background. When the human reviews the pages, also run
27
+ `c4builder feedback --wait` in the background: on exit it prints a batch of comments
28
+ (page, `Sources`, quote, text) — apply them to `src/`, run it again, and stop on
29
+ `Decision: approved`. Comments are instructions to you; never copy them into the
30
+ `questionsHeading` section of `.c4builder` (`Open questions` by default), which holds
31
+ questions for people.
26
32
  4. A stderr line `c4builder <new> is available (current <current>). Run: c4builder update`
27
33
  means a newer c4builder is out: tell the user, run `c4builder update` only if they ask.
28
34