robisie 0.1.0 → 0.1.1

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 (87) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +76 -12
  3. package/dist/active-recovery.d.ts +58 -0
  4. package/dist/active-recovery.js +143 -0
  5. package/dist/agents.d.ts +34 -0
  6. package/dist/agents.js +77 -16
  7. package/dist/attempt-identity.d.ts +21 -0
  8. package/dist/attempt-identity.js +39 -0
  9. package/dist/ci-failure-result.d.ts +23 -0
  10. package/dist/ci-failure-result.js +90 -0
  11. package/dist/claude-headless.d.ts +7 -0
  12. package/dist/claude-headless.js +28 -0
  13. package/dist/cli.d.ts +17 -0
  14. package/dist/cli.js +55 -15
  15. package/dist/commands.d.ts +18 -0
  16. package/dist/commands.js +190 -85
  17. package/dist/constants.d.ts +32 -0
  18. package/dist/constants.js +3 -3
  19. package/dist/context.d.ts +24 -0
  20. package/dist/context.js +22 -6
  21. package/dist/dependency-readiness.d.ts +13 -0
  22. package/dist/dependency-readiness.js +80 -0
  23. package/dist/dispatch-scope.d.ts +18 -0
  24. package/dist/dispatch-scope.js +46 -0
  25. package/dist/done-evidence.d.ts +31 -0
  26. package/dist/done-evidence.js +216 -0
  27. package/dist/engine-config.d.ts +65 -0
  28. package/dist/engine-config.js +139 -0
  29. package/dist/fsops.d.ts +87 -0
  30. package/dist/fsops.js +117 -24
  31. package/dist/helpers.d.ts +15 -0
  32. package/dist/helpers.js +262 -45
  33. package/dist/intent-rules/admission.d.ts +10 -0
  34. package/dist/intent-rules/admission.js +35 -0
  35. package/dist/intent-rules/done-when-roles.d.ts +21 -0
  36. package/dist/intent-rules/done-when-roles.js +101 -0
  37. package/dist/intent-rules/hooks.d.ts +29 -0
  38. package/dist/intent-rules/hooks.js +23 -0
  39. package/dist/k3-codex-role.d.ts +20 -0
  40. package/dist/k3-codex-role.js +68 -0
  41. package/dist/manifest.d.ts +41 -0
  42. package/dist/manifest.js +59 -14
  43. package/dist/mcp.d.ts +18 -0
  44. package/dist/mcp.js +89 -39
  45. package/dist/opencode-result.d.ts +19 -0
  46. package/dist/opencode-result.js +136 -0
  47. package/dist/opencode-review-guard.d.ts +1 -0
  48. package/dist/opencode-review-guard.js +19 -0
  49. package/dist/opencode-session-export.d.ts +5 -0
  50. package/dist/opencode-session-export.js +28 -0
  51. package/dist/outcome-moves.d.ts +15 -0
  52. package/dist/outcome-moves.js +39 -0
  53. package/dist/planer-client.d.ts +214 -0
  54. package/dist/planer-client.js +351 -0
  55. package/dist/restart-recovery.d.ts +47 -0
  56. package/dist/restart-recovery.js +70 -0
  57. package/dist/review-source-packet.d.ts +17 -0
  58. package/dist/review-source-packet.js +44 -0
  59. package/dist/role-registry.d.ts +19 -0
  60. package/dist/role-registry.js +83 -0
  61. package/dist/role-results.d.ts +15 -0
  62. package/dist/role-results.js +42 -0
  63. package/dist/roles.d.ts +30 -0
  64. package/dist/roles.js +20 -16
  65. package/dist/seed-validation.d.ts +28 -0
  66. package/dist/seed-validation.js +294 -0
  67. package/dist/selection-result.d.ts +44 -0
  68. package/dist/selection-result.js +144 -0
  69. package/dist/sparse-policy.d.ts +8 -0
  70. package/dist/sparse-policy.js +49 -0
  71. package/dist/tester-identity.d.ts +11 -0
  72. package/dist/tester-identity.js +53 -0
  73. package/dist/worker-env.d.ts +5 -0
  74. package/dist/worker-env.js +34 -0
  75. package/docs/ENGINE-API.md +78 -0
  76. package/docs/ENGINE-CONFIG.md +115 -0
  77. package/docs/README.en.md +24 -15
  78. package/docs/README.md +26 -15
  79. package/docs/RELEASE-0.1.0.md +81 -8
  80. package/docs/RELEASE-0.1.1.md +110 -0
  81. package/helpers/ceo-client-launcher-mcp.mjs +36 -8
  82. package/helpers/client-spawn-session.sh +181 -0
  83. package/helpers/client-spawn.sh +825 -0
  84. package/package.json +9 -4
  85. package/roles/ceo-client.md +3 -2
  86. package/roles/manager-builder-client.md +18 -4
  87. package/roles/quality-officer-client.md +20 -6
package/dist/fsops.js CHANGED
@@ -3,16 +3,25 @@ import { chmodSync, copyFileSync, lstatSync, mkdirSync, readFileSync, realpathSy
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { parse } from "smol-toml";
6
+ import { tomlStatements } from "./agents.js";
6
7
  import { FILE_MARK, HELPER_SERVERS, MARK_BEGIN, MARK_END, MCP_SERVER_NAME } from "./constants.js";
7
8
  import { RobisieError, errorMessage, shellQuote } from "./context.js";
8
- function readText(path) {
9
+ function readText(path, strict = false) {
10
+ let invalidUtf8 = false;
9
11
  try {
10
- return readFileSync(path, "utf8");
12
+ const bytes = readFileSync(path);
13
+ const text = bytes.toString("utf8");
14
+ invalidUtf8 = strict && !Buffer.from(text, "utf8").equals(bytes);
15
+ if (invalidUtf8)
16
+ throw new Error("Plik nie jest poprawnym UTF-8.");
17
+ return text;
11
18
  }
12
19
  catch (error) {
13
20
  if (error.code === "ENOENT")
14
21
  return null;
15
- throw new RobisieError(`Nie mogę odczytać pliku ${path}: ${errorMessage(error)}`, `Sprawdź uprawnienia do pliku ${path}.`);
22
+ throw new RobisieError(`Nie mogę odczytać pliku ${path}: ${errorMessage(error)}`, invalidUtf8
23
+ ? `Zachowaj oryginalne bajty w osobnej kopii: \`cp -p -n -- ${shellQuote(path)} ${shellQuote(freeCopyPath(path))}\`. Sprawdź, że kopia powstała i zawiera oryginalne bajty. Przywróć poprawną wersję UTF-8 z pewnej kopii, zachowując cudzą konfigurację, i ponów poprzednie polecenie robisie. Bez pewnej wersji zatrzymaj się; nie zapisuj pliku ze znakami zastępczymi.`
24
+ : `Sprawdź uprawnienia do pliku ${path}.`);
16
25
  }
17
26
  }
18
27
  function isSymlink(path) {
@@ -73,8 +82,10 @@ export function refusePrivateSymlink(op) {
73
82
  catch {
74
83
  // dangling — the message still names the link
75
84
  }
85
+ if (target === "?")
86
+ throw new RobisieError(`${op.path} jest zerwanym dowiązaniem symbolicznym — nie zapisuję przez nie klucza.`, `Brak pliku źródłowego dla ${op.path}. Przywróć poprawny plik docelowy z własnej kopii i ponów polecenie; bez dostępnego źródła zatrzymaj się, nic nie zostało zmienione.`);
76
87
  const copy = freeCopyPath(op.path);
77
- throw new RobisieError(`${op.path} jest dowiązaniem symbolicznym do ${target} — nie zapisuję przez nie klucza robisie, bo plik docelowy (np. w repozytorium dotfiles) mógłby trafić z kluczem do historii git.`, `Zamień dowiązanie na zwykły plik: \`cp ${shellQuote(op.path)} ${shellQuote(copy)} && mv ${shellQuote(copy)} ${shellQuote(op.path)}\` (treść zostaje, dowiązanie znika; nazwa kopii jest wolna, więc żadna Twoja kopia nie zostanie nadpisana), i uruchom \`npx robisie init\` ponownie. Albo pomiń tego agenta, np. \`npx robisie init --agent codex\`.`);
88
+ throw new RobisieError(`${op.path} jest dowiązaniem symbolicznym do ${target} — nie zapisuję przez nie klucza robisie, bo plik docelowy (np. w repozytorium dotfiles) mógłby trafić z kluczem do historii git.`, `Zamień dowiązanie na zwykły plik: \`cp ${shellQuote(op.path)} ${shellQuote(copy)} && mv ${shellQuote(copy)} ${shellQuote(op.path)}\` (treść zostaje, dowiązanie znika; nazwa kopii jest wolna, więc żadna Twoja kopia nie zostanie nadpisana), i uruchom \`npx robisie init\` ponownie.`);
78
89
  }
79
90
  /** Mode of an existing file (following links), or null when there is none. */
80
91
  function modeOf(path) {
@@ -119,7 +130,22 @@ function removeFile(path) {
119
130
  function backup(store, path) {
120
131
  const target = join(store.backupDir, `${Date.now()}-${randomBytes(3).toString("hex")}-${basename(path)}`);
121
132
  try {
122
- mkdirSync(store.backupDir, { recursive: true, mode: 0o700 });
133
+ let directory;
134
+ try {
135
+ directory = lstatSync(store.backupDir);
136
+ }
137
+ catch (error) {
138
+ if (error.code !== "ENOENT")
139
+ throw error;
140
+ mkdirSync(store.backupDir, { recursive: true, mode: 0o700 });
141
+ directory = lstatSync(store.backupDir);
142
+ }
143
+ if (directory.isSymbolicLink())
144
+ throw new Error("Katalog kopii zapasowych jest dowiązaniem symbolicznym.");
145
+ if (!directory.isDirectory())
146
+ throw new Error("Miejsce kopii zapasowych nie jest katalogiem.");
147
+ if ((directory.mode & 0o777) !== 0o700)
148
+ chmodSync(store.backupDir, 0o700);
123
149
  copyFileSync(path, target);
124
150
  chmodSync(target, statSync(path).mode & 0o777);
125
151
  }
@@ -156,14 +182,24 @@ export function stripBlock(text, comment) {
156
182
  const [begin, end] = markerLines(comment);
157
183
  while (true) {
158
184
  const lines = text.split("\n");
159
- const first = lines.findIndex((line) => line.replace(/\r$/, "") === begin);
185
+ const first = lines.findIndex((line) => [begin, end].includes(line.replace(/\r$/, "")));
160
186
  if (first === -1)
161
187
  return text;
162
- const last = lines.findIndex((line, index) => index > first && line.replace(/\r$/, "") === end);
188
+ let inside = false;
189
+ const valid = lines.every((line) => {
190
+ const marker = line.replace(/\r$/, "");
191
+ if (marker !== begin && marker !== end)
192
+ return true;
193
+ if ((marker === begin) === inside)
194
+ return false;
195
+ inside = marker === begin;
196
+ return true;
197
+ });
198
+ const last = valid && !inside ? lines.findIndex((line, index) => index > first && line.replace(/\r$/, "") === end) : -1;
163
199
  if (last === -1) {
164
- throw new RobisieError("Znacznik robisie w pliku jest uszkodzony (brak końca bloku).", `Usuń ręcznie linie od „${begin}” i uruchom polecenie ponownie.`);
200
+ throw new RobisieError("Znacznik robisie w pliku jest uszkodzony (brak końca bloku).", `Zachowaj własną kopię pliku. Przywróć poprawną parę znaczników „${begin}” i „${end}” z poprawnej kopii, zachowując wszystkie cudze bajty także za blokiem. Bez kopii lub pewnej granicy bloku zatrzymaj się; nie usuwaj treści do końca pliku.`);
165
201
  }
166
- text = [...lines.slice(0, first), ...lines.slice(last + 1)].join("\n");
202
+ text = lines.slice(0, first).map((line) => `${line}\n`).join("") + lines.slice(last + 1).join("\n");
167
203
  }
168
204
  }
169
205
  export function hasBlock(text, comment) {
@@ -211,9 +247,27 @@ export function assertCodexChange(before, after, body) {
211
247
  }
212
248
  /** Shared by command preflight and the actual write; validate before creating a backup. */
213
249
  export function prepareBlock(current, comment, clean, body) {
214
- let base = stripBlock(current ?? "", comment);
215
- if (clean)
250
+ let base = current ?? "";
251
+ if (clean) {
252
+ // TOML statements keep multiline values intact: only standalone marker lines are ours.
253
+ const [begin, end] = markerLines(comment);
254
+ let inside = false;
255
+ const malformed = () => new RobisieError("Znaczniki robisie w config.toml są uszkodzone (brak pary, odwrócenie lub zagnieżdżenie).", "Popraw pary znaczników robisie w config.toml, zachowując swoją konfigurację, i ponów polecenie.");
256
+ base = tomlStatements(base).filter((statement) => {
257
+ const line = statement.replace(/\r?\n$/, "");
258
+ if (line !== begin && line !== end)
259
+ return true;
260
+ if ((line === begin) === inside)
261
+ throw malformed();
262
+ inside = line === begin;
263
+ return false;
264
+ }).join("");
265
+ if (inside)
266
+ throw malformed();
216
267
  base = clean(base);
268
+ }
269
+ else
270
+ base = stripBlock(base, comment);
217
271
  const next = body === undefined ? base : withBlock(base, renderBlock(comment, body));
218
272
  if (clean)
219
273
  assertCodexChange(current ?? "", next, body);
@@ -320,14 +374,14 @@ export function restore(captured) {
320
374
  // ── apply / undo ─────────────────────────────────────────────────────────────
321
375
  /** Apply one op. `prior` is this path's entry from an earlier init (idempotence: no second backup). */
322
376
  export function applyOp(op, prior, store) {
323
- const current = readText(op.path);
377
+ const current = readText(op.path, op.kind !== "file");
324
378
  const originalMode = prior && "mode" in prior ? prior.mode : modeOf(op.path);
325
379
  if (op.kind === "file") {
326
380
  if (isSymlink(op.path)) {
327
381
  throw new RobisieError(`${op.path} jest dowiązaniem symbolicznym — nie podmieniam go plikiem roli.`, `Przenieś albo usuń dowiązanie ${op.path} i uruchom polecenie ponownie.`);
328
382
  }
329
383
  if (current !== null && prior?.kind === "file" && prior.fingerprint !== undefined && textDigest(current) !== prior.fingerprint) {
330
- throw new RobisieError(`Plik ${op.path} został zmieniony poza robisie — zostawiam go.`, `Kopie zapasowe: ${store.backupDir}. Zachowaj swoje zmiany w osobnej kopii, usuń plik ${op.path} i uruchom \`npx robisie init\` ponownie. Potem przenieś swoje zmiany do nowej roli.`);
384
+ throw new RobisieError(`Plik ${op.path} został zmieniony poza robisie — zostawiam go.`, `Kopie zapasowe: ${store.backupDir}. ${preserveFileStep(op.path)} To plik zarządzany przez robisie. Uruchom \`npx robisie init\` ponownie i wykonaj jego dalsze instrukcje do \`npx robisie doctor\`. Własne zmiany zachowaj w kopii poza zarządzanym plikiem; nie przenoś ich z powrotem. Kopia nie przywraca ich działania jako instrukcji roli.`);
331
385
  }
332
386
  let backupPath = prior?.kind === "file" ? prior.backup : null;
333
387
  if (current !== null && !prior && !(current.includes(FILE_MARK) && current === op.content))
@@ -351,9 +405,10 @@ export function applyOp(op, prior, store) {
351
405
  const obj = current === null ? {} : parseJson(op.path, current);
352
406
  const next = editJson(current ?? "{}\n", op.keyPath, op.value, obj);
353
407
  const changed = next !== current;
408
+ const modeChanged = op.private && modeOf(op.path) !== 0o600;
354
409
  const copy = current !== null && (changed || !priorJson) ? backup(store, op.path) : null;
355
410
  const backupPath = priorJson ? priorJson.backup : copy;
356
- if (changed)
411
+ if (changed || modeChanged)
357
412
  writeText(op.path, next, op.private ? 0o600 : undefined);
358
413
  const entry = {
359
414
  kind: "json",
@@ -364,11 +419,52 @@ export function applyOp(op, prior, store) {
364
419
  fingerprint: fingerprint(op.value),
365
420
  mode: originalMode,
366
421
  };
367
- return { entry, changed };
422
+ return { entry, changed: changed || modeChanged };
423
+ }
424
+ export function preserveFileStep(path) {
425
+ return `Zachowaj plik w osobnej kopii: \`mv -n -- ${shellQuote(path)} ${shellQuote(freeCopyPath(path))}\`.`;
426
+ }
427
+ /** Read-only preflight for advice: do not send init into a predictable refusal. */
428
+ export function checkRecovery(op, prior) {
429
+ refusePrivateSymlink(op);
430
+ if (isSymlink(op.path)) {
431
+ if (op.kind === "file")
432
+ throw new RobisieError(`${op.path} jest dowiązaniem symbolicznym — rola wymaga zwykłego pliku.`, `${preserveFileStep(op.path)} Potem uruchom \`npx robisie init\`.`);
433
+ writeTarget(op.path); // A dangling shared link has no writable target.
434
+ }
435
+ const parent = dirname(op.path);
436
+ try {
437
+ if (!statSync(parent).isDirectory())
438
+ throw new RobisieError(`${parent} jest plikiem zamiast katalogu.`, `${preserveFileStep(parent)} Potem uruchom \`npx robisie init\`.`);
439
+ }
440
+ catch (error) {
441
+ if (error instanceof RobisieError)
442
+ throw error;
443
+ if (error.code !== "ENOENT")
444
+ throw new RobisieError(`Nie mogę sprawdzić katalogu ${parent}: ${errorMessage(error)}`, `Sprawdź uprawnienia do ${parent}.`);
445
+ }
446
+ const current = readText(op.path, op.kind !== "file");
447
+ if (current === null)
448
+ return;
449
+ if (op.kind === "file" && prior?.kind === "file" && prior.fingerprint !== undefined && textDigest(current) !== prior.fingerprint) {
450
+ throw new RobisieError(`Plik ${op.path} został zmieniony poza robisie — zostawiam go.`, `${preserveFileStep(op.path)} To plik zarządzany przez robisie. Uruchom \`npx robisie init\` ponownie i wykonaj jego dalsze instrukcje do \`npx robisie doctor\`. Własne zmiany zachowaj w kopii poza zarządzanym plikiem; nie przenoś ich z powrotem. Kopia nie przywraca ich działania jako instrukcji roli.`);
451
+ }
452
+ if (op.kind === "json") {
453
+ try {
454
+ parseJson(op.path, current);
455
+ }
456
+ catch (error) {
457
+ if (!(error instanceof RobisieError))
458
+ throw error;
459
+ throw new RobisieError(error.message, `${preserveFileStep(op.path)} Cudze bajty pozostają w kopii; potem uruchom \`npx robisie init\`.`);
460
+ }
461
+ }
462
+ if (op.kind === "block")
463
+ prepareBlock(current, op.comment, op.clean, op.body);
368
464
  }
369
465
  /** Whether what init wrote is still in place (doctor). */
370
466
  export function entryPresent(entry) {
371
- const text = readText(entry.path);
467
+ const text = readText(entry.path, entry.kind !== "file");
372
468
  if (text === null)
373
469
  return false;
374
470
  if (entry.kind === "file")
@@ -385,7 +481,7 @@ export function entryPresent(entry) {
385
481
  }
386
482
  /** Revert one entry. A file robisie no longer recognises is left untouched, with a warning. */
387
483
  export function undoEntry(entry, clean) {
388
- const current = readText(entry.path);
484
+ const current = readText(entry.path, entry.kind !== "file");
389
485
  const restoreMode = "mode" in entry ? entry.mode ?? undefined : undefined;
390
486
  if (entry.kind === "file") {
391
487
  if (isSymlink(entry.path))
@@ -409,12 +505,9 @@ export function undoEntry(entry, clean) {
409
505
  if (entry.kind === "block") {
410
506
  let base = prepareBlock(current, entry.comment, clean);
411
507
  // Only undo a separator inserted for an original file without its final newline.
412
- let original = entry.backup ? readText(entry.backup) : null;
413
- if (original !== null) {
414
- original = stripBlock(original, entry.comment);
415
- if (clean)
416
- original = clean(original);
417
- }
508
+ let original = entry.backup ? readText(entry.backup, true) : null;
509
+ if (original !== null)
510
+ original = prepareBlock(original, entry.comment, clean);
418
511
  if (original !== null && !original.endsWith("\n") && base === `${original}\n`)
419
512
  base = original;
420
513
  if (current === null && base === "")
@@ -428,7 +521,7 @@ export function undoEntry(entry, clean) {
428
521
  if (current === null)
429
522
  return null;
430
523
  parseJson(entry.path, current);
431
- const original = entry.backup ? readText(entry.backup) : null;
524
+ const original = entry.backup ? readText(entry.backup, true) : null;
432
525
  const base = editJson(current, entry.keyPath, undefined, original === null ? (entry.created ? {} : parseJson(entry.path, current)) : parseJson(entry.backup, original));
433
526
  if (entry.created && Object.keys(parseJson(entry.path, base)).length === 0 && !isSymlink(entry.path))
434
527
  removeFile(entry.path);
@@ -0,0 +1,15 @@
1
+ import { type HelperServer } from "./constants.js";
2
+ /**
3
+ * The stdio helpers the client roles call, shipped in `helpers/` (verbatim copies of the repo's
4
+ * scripts/*-mcp.mjs, see scripts/sync-assets.mjs). Agents start them as `npx -y robisie@<version> mcp <name>`.
5
+ */
6
+ export declare const HELPER_TOOLS: Record<HelperServer, readonly string[]>;
7
+ export declare function isHelperServer(value: string): value is HelperServer;
8
+ export declare function helperPath(name: HelperServer): string;
9
+ /** `robisie mcp <name>`: run the helper with this process's stdio (the agent talks MCP over it). */
10
+ export declare function runHelper(name: HelperServer): Promise<number>;
11
+ /**
12
+ * Start the helper, do the MCP handshake and list its tools — the proof that the helper actually runs here
13
+ * (its dependencies resolve, Node is new enough). Returns the tool names.
14
+ */
15
+ export declare function probeHelper(name: HelperServer, env: Readonly<Record<string, string | undefined>>, cwd: string): Promise<string[]>;
package/dist/helpers.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { HELPER_SERVERS, PACKAGE_NAME, PACKAGE_VERSION } from "./constants.js";
6
- import { RobisieError, errorMessage } from "./context.js";
6
+ import { RobisieError } from "./context.js";
7
7
  /**
8
8
  * The stdio helpers the client roles call, shipped in `helpers/` (verbatim copies of the repo's
9
9
  * scripts/*-mcp.mjs, see scripts/sync-assets.mjs). Agents start them as `npx -y robisie@<version> mcp <name>`.
@@ -23,73 +23,290 @@ export function helperPath(name) {
23
23
  export function runHelper(name) {
24
24
  const path = helperPath(name);
25
25
  if (!existsSync(path)) {
26
- return Promise.reject(new RobisieError(`Brak pomocnika ${name} w paczce.`, "Przeinstaluj otrzymany plik .tgz w katalogu instalacji: `npm i ./robisie-0.1.0.tgz` (użyj nazwy otrzymanego pliku), potem lokalne `npx robisie init` i `npx robisie doctor`."));
26
+ return Promise.reject(new RobisieError(`Brak pomocnika ${name} w paczce.`, "W katalogu projektu wykonaj `npm uninstall robisie`, potem `npm i robisie@<wersja-zgodna-z-kontraktem-serwera>` (zastąp placeholder numerem zgodnego wydania), potem lokalne `npx robisie init` i `npx robisie doctor`."));
27
27
  }
28
28
  return new Promise((resolve, reject) => {
29
- const child = spawn(process.execPath, [path], { stdio: "inherit" });
30
- child.on("error", (error) => reject(new RobisieError(`Nie mogę uruchomić pomocnika ${name}: ${errorMessage(error)}`, "Sprawdź instalację Node.js (wymagany 20+).")));
31
- child.on("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
29
+ const failure = (stage) => new RobisieError(`Nie mogę uruchomić lub zakończyć pomocnika ${name}: ${stage}.`, "Sprawdź instalację Node.js (wymagany 20+).");
30
+ const detached = process.platform === "darwin" || process.platform === "linux";
31
+ let child;
32
+ try {
33
+ child = spawn(process.execPath, [path], { stdio: detached ? ["pipe", "inherit", "inherit"] : "inherit", detached });
34
+ }
35
+ catch {
36
+ reject(failure("start"));
37
+ return;
38
+ }
39
+ const pid = child.pid;
40
+ const input = process.stdin;
41
+ const parentPid = process.ppid;
42
+ let parentTimer;
43
+ let settled = false;
44
+ let stopping = false;
45
+ let timer;
46
+ let resultCode = 1;
47
+ let resultError;
48
+ let escalateAt = 0;
49
+ let deadline = 0;
50
+ let escalated = false;
51
+ const finish = (error) => {
52
+ if (settled)
53
+ return;
54
+ settled = true;
55
+ clearTimeout(timer);
56
+ clearTimeout(parentTimer);
57
+ process.off("SIGHUP", onHup);
58
+ input.off("end", onEof);
59
+ input.off("close", onEof);
60
+ input.off("error", onStreamError);
61
+ if (detached && child.stdin)
62
+ input.unpipe(child.stdin);
63
+ process.off("SIGINT", onInt);
64
+ process.off("SIGTERM", onTerm);
65
+ child.off("exit", onExit);
66
+ // Keep the guarded error listener until close: error can race with exit/invalid PID.
67
+ if (error)
68
+ reject(error);
69
+ else
70
+ resolve(resultCode);
71
+ };
72
+ const signalGroup = (signal) => {
73
+ try {
74
+ process.kill(-pid, signal);
75
+ return true;
76
+ }
77
+ catch (error) {
78
+ if (error.code !== "ESRCH")
79
+ finish(failure("zakończenie"));
80
+ return false;
81
+ }
82
+ };
83
+ const check = () => {
84
+ if (settled)
85
+ return;
86
+ if (!signalGroup(0)) {
87
+ finish(resultError);
88
+ return;
89
+ }
90
+ if (Date.now() >= deadline) {
91
+ finish(failure("nie mogę potwierdzić zakończenia grupy w ciągu 2 s"));
92
+ return;
93
+ }
94
+ if (!escalated && Date.now() >= escalateAt) {
95
+ escalated = true;
96
+ if (!signalGroup("SIGKILL")) {
97
+ finish(resultError);
98
+ return;
99
+ }
100
+ }
101
+ timer = setTimeout(check, 25);
102
+ };
103
+ const stop = (signal, code, error) => {
104
+ if (settled)
105
+ return;
106
+ resultError ??= error;
107
+ if (stopping)
108
+ return;
109
+ stopping = true;
110
+ resultCode = code;
111
+ escalateAt = Date.now() + 500;
112
+ deadline = Date.now() + 2000;
113
+ if (!signalGroup(signal)) {
114
+ finish(resultError);
115
+ return;
116
+ }
117
+ check();
118
+ };
119
+ const onInt = () => stop("SIGINT", 130);
120
+ const onTerm = () => stop("SIGTERM", 143);
121
+ const onHup = () => stop("SIGHUP", 129);
122
+ const onEof = () => stop("SIGTERM", 143);
123
+ const onStreamError = () => stop("SIGTERM", 1, failure("strumień"));
124
+ const watchParent = () => {
125
+ if (settled || stopping)
126
+ return;
127
+ if (process.ppid !== parentPid)
128
+ onEof();
129
+ else
130
+ parentTimer = setTimeout(watchParent, 250);
131
+ };
132
+ const onExit = (code, signal) => {
133
+ if (settled || stopping)
134
+ return;
135
+ resultCode = code ?? (signal ? 1 : 0);
136
+ if (!detached || !signalGroup(0))
137
+ finish();
138
+ else
139
+ stop("SIGTERM", resultCode);
140
+ };
141
+ const onError = () => {
142
+ if (settled)
143
+ return;
144
+ if (detached && Number.isInteger(pid) && pid > 0)
145
+ stop("SIGTERM", 1, failure("start"));
146
+ else
147
+ finish(failure("start"));
148
+ };
149
+ child.on("error", onError);
150
+ child.once("close", () => {
151
+ child.off("error", onError);
152
+ child.stdin?.off("error", onStreamError);
153
+ });
154
+ child.on("exit", onExit);
155
+ if (!Number.isInteger(pid) || pid <= 0) {
156
+ finish(failure("start (PID)"));
157
+ return;
158
+ }
159
+ if (detached) {
160
+ process.on("SIGINT", onInt);
161
+ process.on("SIGTERM", onTerm);
162
+ process.on("SIGHUP", onHup);
163
+ // The agent may close npx rather than this CLI. Own the input pipe so EOF
164
+ // interrupts active work even when npm does not forward process signals.
165
+ input.on("end", onEof);
166
+ input.on("close", onEof);
167
+ input.on("error", onStreamError);
168
+ child.stdin?.on("error", onStreamError);
169
+ try {
170
+ if (!child.stdin)
171
+ throw new Error("Missing helper stdin");
172
+ input.pipe(child.stdin, { end: false });
173
+ if (input.readableEnded || input.destroyed)
174
+ onEof();
175
+ watchParent();
176
+ }
177
+ catch {
178
+ onStreamError();
179
+ }
180
+ }
32
181
  });
33
182
  }
34
183
  const PROBE_TIMEOUT_MS = 20_000;
184
+ function isRecord(value) {
185
+ return typeof value === "object" && value !== null && !Array.isArray(value);
186
+ }
187
+ function nonEmptyString(value) {
188
+ return typeof value === "string" && value.trim().length > 0;
189
+ }
35
190
  /**
36
191
  * Start the helper, do the MCP handshake and list its tools — the proof that the helper actually runs here
37
192
  * (its dependencies resolve, Node is new enough). Returns the tool names.
38
193
  */
39
194
  export function probeHelper(name, env, cwd) {
40
195
  return new Promise((resolve, reject) => {
41
- // Cast: the repo's Next.js typings make NODE_ENV mandatory on ProcessEnv; the helper does not need it.
42
- const child = spawn(process.execPath, [helperPath(name)], { cwd, env: { ...env }, stdio: ["pipe", "pipe", "pipe"] });
196
+ const failure = (stage) => new Error(`Pomocnik ${name}: ${stage}.`);
197
+ let child;
198
+ try {
199
+ // Cast: the repo's Next.js typings make NODE_ENV mandatory on ProcessEnv; the helper does not need it.
200
+ child = spawn(process.execPath, [helperPath(name)], { cwd, env: { ...env }, stdio: ["pipe", "pipe", "pipe"] });
201
+ }
202
+ catch {
203
+ reject(failure("start"));
204
+ return;
205
+ }
43
206
  let buffer = "";
44
207
  let settled = false;
208
+ let expectedId = 1;
45
209
  const finish = (error, tools) => {
46
210
  if (settled)
47
211
  return;
48
212
  settled = true;
49
213
  clearTimeout(timer);
50
- child.kill();
214
+ buffer = "";
215
+ try {
216
+ child.kill();
217
+ }
218
+ catch {
219
+ error ??= failure("zakończenie");
220
+ }
51
221
  if (error)
52
222
  reject(error);
53
223
  else
54
224
  resolve(tools ?? []);
55
225
  };
56
- const timer = setTimeout(() => finish(new Error("brak odpowiedzi w ciągu 20 s")), PROBE_TIMEOUT_MS);
57
- const send = (message) => child.stdin.write(`${JSON.stringify(message)}\n`);
58
- child.on("error", (error) => finish(error));
59
- child.on("exit", (code) => finish(new Error(`pomocnik zakończył się (kod ${code ?? "?"}) przed odpowiedzią`)));
60
- child.stdin.on("error", () => undefined);
61
- child.stdout.on("data", (chunk) => {
62
- buffer += chunk.toString("utf8");
63
- let newline = buffer.indexOf("\n");
64
- while (newline !== -1) {
65
- const line = buffer.slice(0, newline).trim();
66
- buffer = buffer.slice(newline + 1);
67
- newline = buffer.indexOf("\n");
68
- if (!line)
69
- continue;
70
- let message;
71
- try {
72
- message = JSON.parse(line);
73
- }
74
- catch {
75
- continue;
76
- }
77
- if (message.error)
78
- return finish(new Error(message.error.message ?? "błąd MCP"));
79
- if (message.id === 1) {
80
- send({ jsonrpc: "2.0", method: "notifications/initialized" });
81
- send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
82
- }
83
- else if (message.id === 2) {
84
- return finish(null, (message.result?.tools ?? []).map((tool) => String(tool.name)));
85
- }
226
+ const timer = setTimeout(() => finish(new Error(`Pomocnik ${name}: zakończenie — brak odpowiedzi w ciągu 20 s`)), PROBE_TIMEOUT_MS);
227
+ const send = (message) => {
228
+ if (settled)
229
+ return;
230
+ try {
231
+ child.stdin.write(`${JSON.stringify(message)}\n`);
86
232
  }
87
- });
88
- send({
89
- jsonrpc: "2.0",
90
- id: 1,
91
- method: "initialize",
92
- params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: `${PACKAGE_NAME}-doctor`, version: PACKAGE_VERSION } },
93
- });
233
+ catch {
234
+ finish(failure("strumień"));
235
+ }
236
+ };
237
+ child.on("error", () => finish(failure("start")));
238
+ child.on("exit", (code) => finish(new Error(`Pomocnik ${name}: zakończenie (kod ${typeof code === "number" ? code : "?"}) przed odpowiedzią`)));
239
+ try {
240
+ child.stdin.on("error", () => finish(failure("strumień")));
241
+ child.stdout.on("error", () => finish(failure("strumień")));
242
+ child.stderr.on("error", () => finish(failure("strumień")));
243
+ child.stderr.resume(); // Discard diagnostics without retaining or publishing their contents.
244
+ child.stdout.setEncoding("utf8");
245
+ child.stdout.on("data", (chunk) => {
246
+ if (settled)
247
+ return;
248
+ buffer += chunk;
249
+ let newline = buffer.indexOf("\n");
250
+ while (newline !== -1) {
251
+ const line = buffer.slice(0, newline).trim();
252
+ buffer = buffer.slice(newline + 1);
253
+ newline = buffer.indexOf("\n");
254
+ if (!line)
255
+ continue;
256
+ let message;
257
+ try {
258
+ message = JSON.parse(line);
259
+ }
260
+ catch {
261
+ continue;
262
+ }
263
+ if (!isRecord(message)) {
264
+ return finish(failure("protokół"));
265
+ }
266
+ if (!("id" in message) || message.id !== expectedId)
267
+ continue;
268
+ if (message.jsonrpc !== "2.0" || ("result" in message) === ("error" in message)) {
269
+ return finish(failure("protokół"));
270
+ }
271
+ if ("error" in message) {
272
+ return finish(failure("protokół"));
273
+ }
274
+ if (message.id === 1) {
275
+ const result = message.result;
276
+ if (!isRecord(result) || !isRecord(result.capabilities) || !isRecord(result.serverInfo)
277
+ || !nonEmptyString(result.protocolVersion) || !nonEmptyString(result.serverInfo.name)) {
278
+ return finish(failure("protokół"));
279
+ }
280
+ expectedId = 2;
281
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
282
+ send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
283
+ }
284
+ else if (message.id === 2) {
285
+ const result = "result" in message ? message.result : undefined;
286
+ if (!result || typeof result !== "object" || !("tools" in result) || !Array.isArray(result.tools)) {
287
+ return finish(failure("protokół"));
288
+ }
289
+ const tools = result.tools;
290
+ const names = [];
291
+ for (const tool of tools) {
292
+ if (!tool || typeof tool !== "object" || !("name" in tool) || typeof tool.name !== "string") {
293
+ return finish(failure("protokół"));
294
+ }
295
+ names.push(tool.name);
296
+ }
297
+ return finish(null, names);
298
+ }
299
+ }
300
+ });
301
+ send({
302
+ jsonrpc: "2.0",
303
+ id: 1,
304
+ method: "initialize",
305
+ params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: `${PACKAGE_NAME}-doctor`, version: PACKAGE_VERSION } },
306
+ });
307
+ }
308
+ catch {
309
+ finish(failure("strumień"));
310
+ }
94
311
  });
95
312
  }
@@ -0,0 +1,10 @@
1
+ export declare const ISO_ZONE_FORMAT = "YYYY-MM-DDTHH:mm[:ss[.SSS]]Z or \u00B1HH:mm";
2
+ export declare const ISO_ZONE_EXAMPLE = "2026-10-05T12:00:00+02:00";
3
+ export declare function parseMachineDate(value: unknown): number | null;
4
+ export interface MachineFieldProblem {
5
+ path: string;
6
+ receivedType: string;
7
+ expectedFormat: string;
8
+ example: string;
9
+ }
10
+ export declare function machineFieldProblem(details: Record<string, unknown> | null | undefined, projectId?: string, expectationProject?: string): MachineFieldProblem | null;
@@ -0,0 +1,35 @@
1
+ // Port src/lib/planner/admission.ts z robisie-app (kopia: docs/zrodla/kompilator/admission.ts) — czysta granica pól maszynowych.
2
+ // Jedyna zmiana: projekt oczekiwań (EXPECTATION_PROJECT) nie jest wbity w kod, tylko podawany z robisie.config.json (expectationProject).
3
+ export const ISO_ZONE_FORMAT = 'YYYY-MM-DDTHH:mm[:ss[.SSS]]Z or ±HH:mm';
4
+ export const ISO_ZONE_EXAMPLE = '2026-10-05T12:00:00+02:00';
5
+ export function parseMachineDate(value) {
6
+ if (typeof value !== 'string')
7
+ return null;
8
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-](\d{2}):(\d{2}))$/.exec(value);
9
+ if (!m)
10
+ return null;
11
+ const [year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0] = m.slice(1, 7).map(v => Number(v ?? 0));
12
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
13
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
14
+ if (month < 1 || month > 12 || day < 1 || day > (days[month - 1] ?? 0)
15
+ || hour > 23 || minute > 59 || second > 59 || Number(m[9] ?? 0) > 23 || Number(m[10] ?? 0) > 59)
16
+ return null;
17
+ const parsed = Date.parse(value);
18
+ return Number.isFinite(parsed) ? parsed : null;
19
+ }
20
+ export function machineFieldProblem(details, projectId, expectationProject) {
21
+ const problem = (path, value, object = false) => ({ path,
22
+ receivedType: value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value,
23
+ expectedFormat: object ? 'non-null, non-array object' : ISO_ZONE_FORMAT,
24
+ example: object ? '{"start_condition":"2026-10-05T12:00:00+02:00"}' : ISO_ZONE_EXAMPLE });
25
+ if (details && Object.hasOwn(details, 'admission')) {
26
+ const admission = details.admission;
27
+ if (admission === null || typeof admission !== 'object' || Array.isArray(admission))
28
+ return problem('details.admission', admission, true);
29
+ const record = admission;
30
+ if (Object.hasOwn(record, 'start_condition') && parseMachineDate(record.start_condition) === null)
31
+ return problem('details.admission.start_condition', record.start_condition);
32
+ }
33
+ return details && expectationProject !== undefined && projectId === expectationProject && Object.hasOwn(details, 'termin') && parseMachineDate(details.termin) === null
34
+ ? problem('details.termin', details.termin) : null;
35
+ }