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.
- package/CHANGELOG.md +69 -0
- package/README.md +76 -12
- package/dist/active-recovery.d.ts +58 -0
- package/dist/active-recovery.js +143 -0
- package/dist/agents.d.ts +34 -0
- package/dist/agents.js +77 -16
- package/dist/attempt-identity.d.ts +21 -0
- package/dist/attempt-identity.js +39 -0
- package/dist/ci-failure-result.d.ts +23 -0
- package/dist/ci-failure-result.js +90 -0
- package/dist/claude-headless.d.ts +7 -0
- package/dist/claude-headless.js +28 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.js +55 -15
- package/dist/commands.d.ts +18 -0
- package/dist/commands.js +190 -85
- package/dist/constants.d.ts +32 -0
- package/dist/constants.js +3 -3
- package/dist/context.d.ts +24 -0
- package/dist/context.js +22 -6
- package/dist/dependency-readiness.d.ts +13 -0
- package/dist/dependency-readiness.js +80 -0
- package/dist/dispatch-scope.d.ts +18 -0
- package/dist/dispatch-scope.js +46 -0
- package/dist/done-evidence.d.ts +31 -0
- package/dist/done-evidence.js +216 -0
- package/dist/engine-config.d.ts +65 -0
- package/dist/engine-config.js +139 -0
- package/dist/fsops.d.ts +87 -0
- package/dist/fsops.js +117 -24
- package/dist/helpers.d.ts +15 -0
- package/dist/helpers.js +262 -45
- package/dist/intent-rules/admission.d.ts +10 -0
- package/dist/intent-rules/admission.js +35 -0
- package/dist/intent-rules/done-when-roles.d.ts +21 -0
- package/dist/intent-rules/done-when-roles.js +101 -0
- package/dist/intent-rules/hooks.d.ts +29 -0
- package/dist/intent-rules/hooks.js +23 -0
- package/dist/k3-codex-role.d.ts +20 -0
- package/dist/k3-codex-role.js +68 -0
- package/dist/manifest.d.ts +41 -0
- package/dist/manifest.js +59 -14
- package/dist/mcp.d.ts +18 -0
- package/dist/mcp.js +89 -39
- package/dist/opencode-result.d.ts +19 -0
- package/dist/opencode-result.js +136 -0
- package/dist/opencode-review-guard.d.ts +1 -0
- package/dist/opencode-review-guard.js +19 -0
- package/dist/opencode-session-export.d.ts +5 -0
- package/dist/opencode-session-export.js +28 -0
- package/dist/outcome-moves.d.ts +15 -0
- package/dist/outcome-moves.js +39 -0
- package/dist/planer-client.d.ts +214 -0
- package/dist/planer-client.js +351 -0
- package/dist/restart-recovery.d.ts +47 -0
- package/dist/restart-recovery.js +70 -0
- package/dist/review-source-packet.d.ts +17 -0
- package/dist/review-source-packet.js +44 -0
- package/dist/role-registry.d.ts +19 -0
- package/dist/role-registry.js +83 -0
- package/dist/role-results.d.ts +15 -0
- package/dist/role-results.js +42 -0
- package/dist/roles.d.ts +30 -0
- package/dist/roles.js +20 -16
- package/dist/seed-validation.d.ts +28 -0
- package/dist/seed-validation.js +294 -0
- package/dist/selection-result.d.ts +44 -0
- package/dist/selection-result.js +144 -0
- package/dist/sparse-policy.d.ts +8 -0
- package/dist/sparse-policy.js +49 -0
- package/dist/tester-identity.d.ts +11 -0
- package/dist/tester-identity.js +53 -0
- package/dist/worker-env.d.ts +5 -0
- package/dist/worker-env.js +34 -0
- package/docs/ENGINE-API.md +78 -0
- package/docs/ENGINE-CONFIG.md +115 -0
- package/docs/README.en.md +24 -15
- package/docs/README.md +26 -15
- package/docs/RELEASE-0.1.0.md +81 -8
- package/docs/RELEASE-0.1.1.md +110 -0
- package/helpers/ceo-client-launcher-mcp.mjs +36 -8
- package/helpers/client-spawn-session.sh +181 -0
- package/helpers/client-spawn.sh +825 -0
- package/package.json +9 -4
- package/roles/ceo-client.md +3 -2
- package/roles/manager-builder-client.md +18 -4
- 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
|
-
|
|
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)}`,
|
|
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
|
|
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
|
-
|
|
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$/, "")
|
|
185
|
+
const first = lines.findIndex((line) => [begin, end].includes(line.replace(/\r$/, "")));
|
|
160
186
|
if (first === -1)
|
|
161
187
|
return text;
|
|
162
|
-
|
|
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).", `
|
|
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 =
|
|
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 =
|
|
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}.
|
|
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 =
|
|
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
|
|
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.`, "
|
|
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
|
|
30
|
-
|
|
31
|
-
child
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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(
|
|
57
|
-
const send = (message) =>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
}
|