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/commands.js CHANGED
@@ -1,26 +1,60 @@
1
- import { readdirSync, rmdirSync, rmSync } from "node:fs";
2
- import { AGENT_LABELS, SHELL_EXPORT, agentPaths, agentOnPath, detectAgents, planAgent, removeCodexTables, shellProfile } from "./agents.js";
1
+ import { lstatSync, readdirSync, rmdirSync, rmSync } from "node:fs";
2
+ import { AGENT_IDS, AGENT_LABELS, SHELL_EXPORT, agentPaths, agentOnPath, detectAgents, planAgent, removeCodexTables, shellProfile } from "./agents.js";
3
3
  import { HELPER_SERVERS, KEY_ENV, KEY_PATTERN, PACKAGE_NAME, PACKAGE_VERSION } from "./constants.js";
4
4
  import { RobisieError, errorMessage } from "./context.js";
5
- import { applyOp, assertCodexChange, capture, entryPresent, prepareBlock, refusePrivateSymlink, restore, undoEntry } from "./fsops.js";
5
+ import { applyOp, assertCodexChange, checkRecovery, preserveFileStep, capture, entryPresent, fingerprint, prepareBlock, refusePrivateSymlink, renderBlock, restore, undoEntry } from "./fsops.js";
6
6
  import { HELPER_TOOLS, probeHelper } from "./helpers.js";
7
7
  import { clientPaths, ensurePrivateDir, readKey, readManifest, writeKey, writeManifest, roleProviders, ROLE_IDS, ROLE_STEP } from "./manifest.js";
8
8
  import { assertCompatible, checkConnection, fetchContractVersion } from "./mcp.js";
9
9
  import { loadRoles, rolesDir } from "./roles.js";
10
10
  const NO_KEY_STEP = "Wygeneruj klucz worker na https://robisie.app/dashboard/connect; w zsh/bash wykonaj `unset ROBISIE_KEY`, potem lokalne `npx robisie init` i wklej klucz w ukrytym pytaniu (poza rozmową).";
11
+ const KEY_FORMAT_STEP = `Skopiuj klucz jeszcze raz z https://robisie.app/dashboard/connect; uruchom lokalne \`npx robisie init\` i wklej klucz w ukrytym pytaniu (poza rozmową). Alternatywnie przekaż klucz przez stdin z jawną flagą: \`npx robisie init --key-stdin\` (poza rozmową i historią poleceń). Następnie wczytaj zapisany klucz do bieżącej sesji POSIX z tym samym XDG_CONFIG_HOME: \`${SHELL_EXPORT}\`, potem uruchom \`npx robisie doctor\`.`;
12
+ const ENV_KEY_FORMAT_STEP = `W zsh/bash wykonaj \`unset ROBISIE_KEY\` — ENV ma pierwszeństwo przed pytaniem, stdin i plikiem. ${KEY_FORMAT_STEP}`;
13
+ /** Advice chooses an executable CLI, not merely a leftover configuration directory. */
14
+ export function initRecoveryStep(ctx, preferred) {
15
+ let prior;
16
+ try {
17
+ prior = readManifest(clientPaths(agentPaths(ctx).clientDir).manifest);
18
+ }
19
+ catch (error) {
20
+ return error instanceof RobisieError ? error.nextStep : `Nie mogę sprawdzić ewidencji instalacji: ${errorMessage(error)}. Sprawdź jej plik i uprawnienia przed ponowieniem init.`;
21
+ }
22
+ const missing = [...new Set(prior?.agents ?? [])].filter((id) => !agentOnPath(ctx, id));
23
+ const historyStep = missing.map((id) => `Zainstaluj program ${id} lub dodaj go do PATH.`).join(" ");
24
+ const provider = preferred && agentOnPath(ctx, preferred) ? preferred : AGENT_IDS.find((id) => agentOnPath(ctx, id));
25
+ if (!provider)
26
+ return `${historyStep || "Zainstaluj zewnętrznie Claude Code, Codex CLI albo OpenCode i dodaj program do PATH."} Potem ponów \`npx robisie doctor\`, aby przypisać role ceo, builder, quality.`;
27
+ return `${historyStep ? `${historyStep} Zachowana historia instalacji nadal wymaga tych programów. Potem: ` : ""}Uruchom \`npx robisie init --agent ${provider} --role ceo=${provider} --role builder=${provider} --role quality=${provider}\` (role: ceo, builder, quality; dostawcy: claude, codex, opencode).`;
28
+ }
29
+ export function recoveryStep(ctx, step) {
30
+ return step.includes(ROLE_STEP) ? step.replace(ROLE_STEP, initRecoveryStep(ctx)) : step;
31
+ }
32
+ function ceoProjectStep(count) {
33
+ return (count === 0
34
+ ? "Sprawdź gotowość projektu na https://robisie.app/dashboard/plugins/planer."
35
+ : "Uporządkuj widoczność projektów w discovery do 1; sam wybór projektu w panelu nie zmienia discovery.")
36
+ + " Potem uruchom `npx robisie doctor`.";
37
+ }
11
38
  function entryId(entry) {
12
39
  return `${entry.kind}:${entry.path}:${entry.keyPath?.join(".") ?? ""}`;
13
40
  }
14
41
  async function obtainKey(ctx, keyStdin) {
15
42
  let key = ctx.env[KEY_ENV]?.trim() ?? "";
16
- if (!key && ctx.askSecret)
43
+ let source = KEY_ENV;
44
+ if (!key && ctx.askSecret) {
45
+ source = "ukrytego pytania";
17
46
  key = (await ctx.askSecret("Wklej klucz robisie (rob_…, nie będzie widoczny): ")).trim();
18
- if (!key && keyStdin && ctx.readStdin)
47
+ }
48
+ if (!key && keyStdin && ctx.readStdin) {
49
+ source = "stdin";
19
50
  key = (await ctx.readStdin()).trim().split(/\r?\n/)[0]?.trim() ?? "";
51
+ }
20
52
  if (!key)
21
- throw new RobisieError("Brak klucza robisie.", NO_KEY_STEP);
53
+ throw new RobisieError("Brak klucza robisie.", ctx.askSecret === null && !keyStdin
54
+ ? `${NO_KEY_STEP} Brak terminala do ukrytego pytania; uruchom init w terminalu albo przekaż klucz przez stdin z jawną flagą --key-stdin (poza rozmową).`
55
+ : NO_KEY_STEP);
22
56
  if (!KEY_PATTERN.test(key))
23
- throw new RobisieError("To nie wygląda na klucz robisie (oczekuję rob_ i 64 znaków 0-9a-f).", "Skopiuj klucz jeszcze raz z https://robisie.app/dashboard/connect i uruchom `npx robisie init`.");
57
+ throw new RobisieError(`Klucz z ${source} nie wygląda na klucz robisie (oczekuję rob_ i 64 znaków 0-9a-f).`, source === KEY_ENV ? ENV_KEY_FORMAT_STEP : KEY_FORMAT_STEP);
24
58
  return key;
25
59
  }
26
60
  export async function init(ctx, options) {
@@ -36,8 +70,9 @@ export async function init(ctx, options) {
36
70
  map = roleProviders({ agents: [], roleProviders: merged });
37
71
  }
38
72
  const selected = [...new Set([...Object.values(map ?? {}), ...detected])];
73
+ const mapChanged = prior !== null && ROLE_IDS.some((role) => previousMap?.[role] !== map?.[role]);
39
74
  if (selected.length === 0) {
40
- throw new RobisieError("Nie znalazłem na tym komputerze Claude Code, Codex CLI ani OpenCode.", "Zainstaluj jednego z tych agentów albo wskaż go wprost, np. `npx robisie init --agent codex`.");
75
+ throw new RobisieError("Nie znalazłem na tym komputerze Claude Code, Codex CLI ani OpenCode.", initRecoveryStep(ctx));
41
76
  }
42
77
  const key = await obtainKey(ctx, options.keyStdin);
43
78
  // Refuse before writing anything: incompatible server, or a key that does not open the board.
@@ -53,14 +88,16 @@ export async function init(ctx, options) {
53
88
  const profile = options.shell ? shellProfile(ctx) : null;
54
89
  if (profile)
55
90
  planned.push({ owner: "shell", op: { kind: "block", path: profile, comment: "hash", body: SHELL_EXPORT } });
91
+ const priorById = new Map((prior?.entries ?? []).map((entry) => [entryId(entry), entry]));
56
92
  // Before writing anything, so a refusal leaves no half-done install behind.
57
93
  for (const { op } of planned) {
58
94
  refusePrivateSymlink(op);
95
+ const before = capture(op.path);
59
96
  if (op.kind === "block" && op.clean)
60
- prepareBlock(capture(op.path).bytes?.toString("utf8") ?? null, op.comment, op.clean, op.body);
97
+ prepareBlock(before.bytes?.toString("utf8") ?? null, op.comment, op.clean, op.body);
98
+ checkRecovery(op, priorById.get(entryId(op)));
61
99
  }
62
100
  ensurePrivateDir(paths.dir);
63
- const priorById = new Map((prior?.entries ?? []).map((entry) => [entryId(entry), entry]));
64
101
  const done = new Map(priorById);
65
102
  const agents = [...new Set([...(prior?.agents ?? []), ...selected])];
66
103
  let changed = 0;
@@ -99,14 +136,18 @@ export async function init(ctx, options) {
99
136
  changed += 1;
100
137
  }
101
138
  ctx.out(`robisie ${PACKAGE_VERSION}: połączono z tablicą (projektów: ${connection.projectCount}).`);
139
+ if (connection.projectCount !== 1)
140
+ ctx.out(`! CEO nie jest gotowy: wymaga dokładnie 1 projektu (projektów: ${connection.projectCount}). ${ceoProjectStep(connection.projectCount)}`);
102
141
  for (const id of selected)
103
142
  ctx.out(`✓ ${AGENT_LABELS[id]}: serwer MCP robisie i role klienta (CEO, Builder, Quality Officer) zainstalowane.`);
104
- ctx.out(changed === 0 ? "Nic się nie zmieniło — wszystko było już zainstalowane." : `Zmienione pliki: ${changed} (kopie zapasowe: ${paths.backups}).`);
143
+ ctx.out(changed === 0 ? (mapChanged ? "✓ mapa ról zmieniona; konfiguracja agentów bez zmian." : "Nic się nie zmieniło — wszystko było już zainstalowane.") : `Zmienione pliki: ${changed} (kopie zapasowe: ${paths.backups}).`);
105
144
  if (profile)
106
145
  ctx.out(`✓ Klucz będzie dostępny jako ${KEY_ENV} w nowych terminalach (wpis w ${profile}).`);
107
- else
108
- ctx.out(`! Ustaw sam zmienną ${KEY_ENV} dla agenta, np.: ${SHELL_EXPORT}`);
109
- ctx.out("Następny krok: otwórz nowy terminal i uruchom `npx robisie doctor`.");
146
+ else {
147
+ const reason = !options.shell ? "--no-shell" : !ctx.env.SHELL ? "brak SHELL" : "niewspierana powłoka/platforma";
148
+ ctx.out(`! Pominięto profil: ${reason}. Ustaw sam zmienną ${KEY_ENV} dla agenta, np.: ${SHELL_EXPORT}`);
149
+ }
150
+ ctx.out(`Następny krok: ${!map || map.ceo !== map.builder || map.ceo !== map.quality ? initRecoveryStep(ctx, map?.ceo) : "otwórz nowy terminal i uruchom `npx robisie doctor`."}`);
110
151
  }
111
152
  // ── doctor ───────────────────────────────────────────────────────────────────
112
153
  /** Returns the process exit code: 0 = all good, 1 = something needs one action. */
@@ -114,7 +155,7 @@ export async function doctor(ctx) {
114
155
  const problems = [];
115
156
  const fail = (line, nextStep) => {
116
157
  ctx.out(`✗ ${line}`);
117
- problems.push(nextStep);
158
+ problems.push(recoveryStep(ctx, nextStep));
118
159
  };
119
160
  const guard = async (action) => {
120
161
  try {
@@ -129,6 +170,11 @@ export async function doctor(ctx) {
129
170
  };
130
171
  ctx.out(`Paczka ${PACKAGE_NAME} ${PACKAGE_VERSION}`);
131
172
  const paths = clientPaths(agentPaths(ctx).clientDir);
173
+ // Ledger recovery must remain the next step even when network checks fail.
174
+ let manifest = null;
175
+ await guard(async () => {
176
+ manifest = readManifest(paths.manifest);
177
+ });
132
178
  await guard(async () => {
133
179
  const version = await fetchContractVersion(ctx);
134
180
  assertCompatible(version);
@@ -136,26 +182,28 @@ export async function doctor(ctx) {
136
182
  });
137
183
  const envKey = ctx.env[KEY_ENV]?.trim() || null;
138
184
  let key = envKey;
185
+ let keyRead = false;
139
186
  await guard(async () => {
140
187
  key = envKey ?? readKey(paths.dir);
188
+ keyRead = true;
141
189
  });
142
190
  const usableKey = key;
143
- if (!usableKey)
144
- fail("Brak klucza robisie.", NO_KEY_STEP);
191
+ if (!usableKey) {
192
+ if (keyRead)
193
+ fail("Brak klucza robisie.", NO_KEY_STEP);
194
+ }
145
195
  else if (!KEY_PATTERN.test(usableKey))
146
- fail("Zapisany klucz ma zły format.", "Uruchom `npx robisie init` i wklej klucz jeszcze raz.");
196
+ fail(`${envKey ? "Klucz z ROBISIE_KEY" : "Klucz w pliku"} ma zły format.`, envKey ? ENV_KEY_FORMAT_STEP : KEY_FORMAT_STEP);
147
197
  else {
148
198
  await guard(async () => {
149
199
  const connection = await checkConnection(ctx, usableKey);
150
200
  if (connection.contractVersion)
151
201
  assertCompatible(connection.contractVersion);
152
202
  ctx.out(`✓ Połączenie działa: klucz otwiera tablicę (projektów: ${connection.projectCount}).`);
203
+ if (connection.projectCount !== 1)
204
+ fail(`CEO wymaga dokładnie 1 projektu (projektów: ${connection.projectCount}).`, ceoProjectStep(connection.projectCount));
153
205
  });
154
206
  }
155
- let manifest = null;
156
- await guard(async () => {
157
- manifest = readManifest(paths.manifest);
158
- });
159
207
  const installed = manifest;
160
208
  let map = null;
161
209
  if (installed)
@@ -164,19 +212,45 @@ export async function doctor(ctx) {
164
212
  for (const role of ROLE_IDS)
165
213
  ctx.out(`${role} → ${assignment?.[role] ?? "nie przypisano"}`);
166
214
  if (!installed || installed.agents.length === 0) {
167
- fail("robisie nie jest zainstalowane dla żadnego agenta.", "Uruchom `npx robisie init`.");
215
+ fail("robisie nie jest zainstalowane dla żadnego agenta.", initRecoveryStep(ctx));
168
216
  }
169
217
  else {
218
+ for (const id of new Set(assignment ? Object.values(assignment) : installed.agents)) {
219
+ if (!agentOnPath(ctx, id)) {
220
+ const assigned = assignment ? ROLE_IDS.filter((role) => assignment[role] === id).join(", ") : "nie przypisano";
221
+ fail(`${assigned} → ${id}: ${detectAgents(ctx).includes(id) ? "katalog jest, programu nie ma w PATH" : "brak programu w PATH i katalogu dostawcy"}.`, `Zainstaluj program ${id} lub dodaj go do PATH i ponów \`npx robisie doctor\`.`);
222
+ }
223
+ }
170
224
  const byId = new Map(installed.entries.map((entry) => [entryId(entry), entry]));
171
225
  for (const id of new Set([...installed.agents, ...Object.values(assignment ?? {})])) {
172
- const repair = `Uruchom \`npx robisie init --agent ${id}\` — uzupełni braki.`;
226
+ const repair = `Uruchom \`npx robisie init --agent ${id}\`${id === "claude" ? " z właściwym ROBISIE_KEY w środowisku" : ""} — uzupełni braki.`;
173
227
  await guard(async () => {
174
228
  // A complete install = every change init plans for this agent is recorded AND still in place.
175
229
  const expected = planAgent(ctx, id, loadRoles(), usableKey ?? `rob_${"0".repeat(64)}`);
176
230
  const missing = expected.filter((op) => {
177
231
  const entry = byId.get(entryId(op));
232
+ try {
233
+ checkRecovery(op, entry);
234
+ }
235
+ catch (error) {
236
+ if (!(error instanceof RobisieError))
237
+ throw error;
238
+ const detail = assignment ? ROLE_IDS.filter((role) => assignment[role] === id).map((role) => `${role} → ${id}: niepełna instalacja`).join(", ") : "";
239
+ throw new RobisieError(`${AGENT_LABELS[id]}: ${error.message}${detail ? ` Role: ${detail}.` : ""}`, error.nextStep);
240
+ }
178
241
  if (!entry || entry.owner !== id || !entryPresent(entry))
179
242
  return true;
243
+ if (op.kind === "file" && capture(op.path).bytes?.toString("utf8") !== op.content)
244
+ return true;
245
+ if (op.kind === "block" && op.comment === "html") {
246
+ const current = (capture(op.path).bytes?.toString("utf8") ?? "").replace(/\r\n/g, "\n").split("\n");
247
+ const planned = renderBlock(op.comment, op.body).trimEnd().split("\n");
248
+ const start = current.indexOf(planned[0] ?? "");
249
+ if (start < 0 || current.slice(start, start + planned.length).join("\n") !== planned.join("\n"))
250
+ return true;
251
+ }
252
+ if (id === "claude" && op.kind === "json" && (entry.kind !== "json" || fingerprint(op.value) !== entry.fingerprint))
253
+ return true;
180
254
  if (op.kind === "block" && op.clean) {
181
255
  try {
182
256
  const current = capture(op.path).bytes?.toString("utf8") ?? "";
@@ -201,16 +275,13 @@ export async function doctor(ctx) {
201
275
  }
202
276
  });
203
277
  }
204
- for (const id of new Set(assignment ? Object.values(assignment) : installed.agents)) {
205
- if (!agentOnPath(ctx, id)) {
206
- const assigned = assignment ? ROLE_IDS.filter((role) => assignment[role] === id).join(", ") : "nie przypisano";
207
- fail(`${assigned} → ${id}: ${detectAgents(ctx).includes(id) ? "katalog jest, programu nie ma w PATH" : "brak programu w PATH i katalogu dostawcy"}.`, `Zainstaluj program ${id} lub dodaj go do PATH i ponów \`npx robisie doctor\`.`);
208
- }
278
+ if (!assignment) {
279
+ const provider = installed.agents[0];
280
+ fail("Role: nie przypisano dostawców.", `Uruchom \`npx robisie init --agent ${provider} --role ceo=${provider} --role builder=${provider} --role quality=${provider}\`. Aby wybrać innego zainstalowanego dostawcę (${installed.agents.join(", ")}), zastąp ${provider} jego nazwą we wszystkich czterech miejscach.`);
209
281
  }
210
- if (!assignment)
211
- fail("Role: nie przypisano dostawców.", `Uruchom npx robisie init z pełną mapą. ${ROLE_STEP}`);
212
282
  else if (assignment.builder !== assignment.ceo || assignment.quality !== assignment.ceo) {
213
- fail(`Mieszana mapa: Quality Officer jako podagent u dostawcy CEO (${assignment.ceo}); polecenie Buildera w formacie dostawcy CEO (${assignment.ceo}). Wykonanie mieszanych ról to następny zakres.`, "Wybierz jednego dostawcę dla trzech ról przez `npx robisie init --role ceo=claude --role builder=claude --role quality=claude` albo zachowaj mapę i poczekaj na obsługę mieszanych ról.");
283
+ const provider = assignment.ceo;
284
+ fail(`Mieszana mapa: Quality Officer jako podagent u dostawcy CEO (${assignment.ceo}); polecenie Buildera w formacie dostawcy CEO (${assignment.ceo}). Wykonanie mieszanych ról to następny zakres.`, `Wybierz jednego dostawcę dla trzech ról przez \`npx robisie init --agent ${provider} --role ceo=${provider} --role builder=${provider} --role quality=${provider}\`. Aby wybrać innego zainstalowanego dostawcę (${installed.agents.join(", ")}), zastąp ${provider} jego nazwą we wszystkich czterech miejscach albo zachowaj mapę i poczekaj na obsługę mieszanych ról.`);
214
285
  }
215
286
  for (const name of HELPER_SERVERS) {
216
287
  await guard(async () => {
@@ -219,11 +290,11 @@ export async function doctor(ctx) {
219
290
  tools = await probeHelper(name, ctx.env, ctx.cwd);
220
291
  }
221
292
  catch (error) {
222
- throw new RobisieError(`Pomocnik ${name} nie działa na tym komputerze: ${errorMessage(error)}`, "Sprawdź Node.js (wymagany 20+). 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`.");
293
+ throw new RobisieError(`Pomocnik ${name} nie działa na tym komputerze: ${errorMessage(error)}`, "Sprawdź Node.js (wymagany 20+). Przeinstaluj paczkę z npm w katalogu instalacji: `npm i robisie@latest`, potem lokalne `npx robisie init` i `npx robisie doctor`.");
223
294
  }
224
295
  const absent = HELPER_TOOLS[name].filter((tool) => !tools.includes(tool));
225
296
  if (absent.length > 0)
226
- throw new RobisieError(`Pomocnik ${name} nie ma narzędzi: ${absent.join(", ")}.`, "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`.");
297
+ throw new RobisieError(`Pomocnik ${name} nie ma narzędzi: ${absent.join(", ")}.`, "Przeinstaluj paczkę z npm w katalogu instalacji: `npm i robisie@latest`, potem lokalne `npx robisie init` i `npx robisie doctor`.");
227
298
  ctx.out(`✓ Pomocnik ${name} działa (${tools.length} narzędzi).`);
228
299
  });
229
300
  }
@@ -250,69 +321,103 @@ function removeEmptyDir(path) {
250
321
  return true;
251
322
  if (code === "ENOTEMPTY" || code === "EEXIST")
252
323
  return false;
253
- throw new RobisieError(`Nie mogę usunąć katalogu ${path}: ${errorMessage(error)}`, `Usuń katalog ${path} ręcznie.`);
324
+ throw new RobisieError(`Nie mogę usunąć katalogu ${path}: ${errorMessage(error)}`, `Sprawdź uprawnienia i usuń przeszkodę uniemożliwiającą opróżnienie katalogu ${path}, zachowując ewidencję instalacji i kopie użytkownika. Potem ponów \`npx robisie uninstall\`.`);
254
325
  }
255
326
  }
256
327
  export function uninstall(ctx) {
257
328
  const agent = agentPaths(ctx);
258
329
  const paths = clientPaths(agent.clientDir);
259
- const manifest = readManifest(paths.manifest);
260
- let hasBackups = false;
261
330
  try {
262
- hasBackups = readdirSync(paths.backups).length > 0;
263
- }
264
- catch (error) {
265
- if (error.code !== "ENOENT") {
266
- throw new RobisieError(`Nie mogę sprawdzić kopii zapasowych: ${errorMessage(error)}`, `Sprawdź uprawnienia do ${paths.backups} i ponów polecenie.`);
331
+ const manifest = readManifest(paths.manifest);
332
+ let hasBackups = false;
333
+ try {
334
+ hasBackups = readdirSync(paths.backups).length > 0;
267
335
  }
268
- }
269
- const backupNotice = hasBackups ? `Kopie zapasowe pozostają w ${paths.backups}.` : "";
270
- if (!manifest) {
271
- ctx.out("Nie znaleziono zapisu instalacji — nic nie zmieniono (0 zmian).");
272
- ctx.out(`Jeśli wpis robisie pozostał, usuń go ręcznie z konfiguracji swojego agenta: ${agent.claudeJson}, ${agent.codexConfig} lub ${agent.opencodeJson} (albo ${agent.opencodeJsonc}).`);
273
- if (backupNotice)
274
- ctx.out(backupNotice);
275
- return 0;
276
- }
277
- const pending = new Set(manifest.entries);
278
- // The manifest records the installed location; CODEX_HOME may have changed since init.
279
- const codexConfigs = new Set(manifest.entries.filter((entry) => entry.kind === "block" && entry.comment === "hash" && (entry.owner === "codex" || entry.path === agent.codexConfig)).map((entry) => entry.path));
280
- const evidence = `${backupNotice ? `${backupNotice} ` : ""}Pozostałe zmiany: ${paths.manifest}.`;
281
- try {
282
- // Refuse unsafe Codex edits before deleting the key, undoing roles or recording progress.
283
- for (const entry of manifest.entries) {
284
- if (entry.kind === "block" && codexConfigs.has(entry.path)) {
285
- prepareBlock(capture(entry.path).bytes?.toString("utf8") ?? null, entry.comment, (text) => removeCodexTables(text, true));
336
+ catch (error) {
337
+ if (error.code !== "ENOENT") {
338
+ throw new RobisieError(`Nie mogę sprawdzić kopii zapasowych: ${errorMessage(error)}`, `Sprawdź uprawnienia do ${paths.backups} i ponów polecenie.`);
286
339
  }
287
340
  }
288
- // The key is not needed to finish an uninstall, even after a warning or a failure.
289
- rmSync(paths.key, { force: true });
290
- for (const entry of [...manifest.entries].reverse()) {
291
- const warning = undoEntry(entry, codexConfigs.has(entry.path) ? (text) => removeCodexTables(text, true) : undefined);
292
- if (warning)
293
- ctx.out(`! ${warning} ${evidence}`);
294
- else {
295
- pending.delete(entry);
296
- // A later init must not mistake an already restored user role for an edited installed one.
297
- writeManifest(paths.dir, { agents: manifest.agents, roleProviders: manifest.roleProviders, entries: [...pending] });
341
+ const backupNotice = hasBackups ? `Kopie zapasowe pozostają w ${paths.backups}.` : "";
342
+ if (!manifest) {
343
+ ctx.out("Nie znaleziono zapisu instalacji — nic nie zmieniono (0 zmian).");
344
+ ctx.out(`Jeśli wpis robisie pozostał, usuń go ręcznie z konfiguracji swojego agenta: ${agent.claudeJson}, ${agent.codexConfig} lub ${agent.opencodeJson} (albo ${agent.opencodeJsonc}).`);
345
+ if (backupNotice)
346
+ ctx.out(backupNotice);
347
+ return 0;
348
+ }
349
+ const pending = new Set(manifest.entries);
350
+ // The manifest records the installed location; CODEX_HOME may have changed since init.
351
+ const codexConfigs = new Set(manifest.entries.filter((entry) => entry.kind === "block" && entry.comment === "hash" && (entry.owner === "codex" || entry.path === agent.codexConfig)).map((entry) => entry.path));
352
+ const evidence = `${backupNotice ? `${backupNotice} ` : ""}Pozostałe zmiany: ${paths.manifest}.`;
353
+ const leftovers = [];
354
+ try {
355
+ // Refuse missing role originals and unsafe Codex edits before any uninstall mutation.
356
+ for (const entry of manifest.entries) {
357
+ if (entry.kind === "file" && entry.backup && capture(entry.backup).bytes === null) {
358
+ throw new RobisieError(`Brak kopii oryginału ${entry.backup} dla ${entry.path}.`, `Przywróć tę kopię z własnego archiwum do ${entry.backup}; bez niej zatrzymaj się, nie usuwaj pliku ${entry.path} i nie ponawiaj uninstall. Po przywróceniu kopii ponów \`npx robisie uninstall\`.`);
359
+ }
360
+ if (entry.kind === "block" && codexConfigs.has(entry.path)) {
361
+ prepareBlock(capture(entry.path).bytes?.toString("utf8") ?? null, entry.comment, (text) => removeCodexTables(text, true));
362
+ }
363
+ }
364
+ // The key is not needed to finish an uninstall, even after a warning or a failure.
365
+ rmSync(paths.key, { force: true });
366
+ for (const entry of [...manifest.entries].reverse()) {
367
+ const warning = undoEntry(entry, codexConfigs.has(entry.path) ? (text) => removeCodexTables(text, true) : undefined);
368
+ if (warning)
369
+ ctx.out(`! ${warning} ${evidence}`);
370
+ else {
371
+ pending.delete(entry);
372
+ // A later init must not mistake an already restored user role for an edited installed one.
373
+ writeManifest(paths.dir, { agents: manifest.agents, roleProviders: manifest.roleProviders, entries: [...pending] });
374
+ }
375
+ }
376
+ if (pending.size > 0) {
377
+ ctx.out(`! Nie udało się cofnąć wszystkich zmian. ${evidence}`);
378
+ const next = [...pending].map((entry) => `${preserveFileStep(entry.path)}${entry.backup ? ` Przywróć oryginał z kopii ${entry.backup} do ${entry.path}, zachowując tę kopię.` : ""}`).join(" ");
379
+ ctx.out(`Następny krok: ${next} Potem ponów \`npx robisie uninstall\`.`);
380
+ return 1;
381
+ }
382
+ for (const dir of [paths.codexRoles, paths.roles])
383
+ if (!removeEmptyDir(dir))
384
+ leftovers.push(dir);
385
+ // The ledger lives inside paths.dir, so only the final rmdir requires removing it first.
386
+ try {
387
+ rmSync(paths.manifest, { force: true });
388
+ if (!removeEmptyDir(paths.dir))
389
+ leftovers.push(paths.dir);
390
+ }
391
+ catch (error) {
392
+ try {
393
+ writeManifest(paths.dir, { agents: manifest.agents, roleProviders: manifest.roleProviders, entries: [...pending] });
394
+ }
395
+ catch (restoreError) {
396
+ throw new RobisieError(`${errorMessage(error)} Nie udało się odtworzyć ewidencji ${paths.manifest}: ${errorMessage(restoreError)}`, `Zachowaj katalog ${paths.dir} i kopie użytkownika. ${restoreError instanceof RobisieError ? restoreError.nextStep : "Sprawdź uprawnienia do katalogu ewidencji."} Nie ponawiaj \`npx robisie uninstall\` bez poprawnej ewidencji instalacji.`);
397
+ }
398
+ throw error;
298
399
  }
299
400
  }
300
- if (pending.size > 0) {
301
- ctx.out(`! Nie udało się cofnąć wszystkich zmian. ${evidence}`);
302
- ctx.out("Następny krok: porównaj pozostawione pliki z kopiami, zachowaj swoje zmiany i ponów `npx robisie uninstall`.");
303
- return 1;
401
+ catch (error) {
402
+ throw new RobisieError(`Nie udało się dokończyć odinstalowania: ${errorMessage(error)}`, `${error instanceof RobisieError ? error.nextStep : "Rozwiąż problem i ponów `npx robisie uninstall`."} ${evidence}`);
304
403
  }
305
- rmSync(paths.manifest, { force: true });
404
+ ctx.out(`✓ Usunięto wpisy i bloki robisie oraz cofnięto instalację ról (${manifest.entries.length} zmian).`);
405
+ if (backupNotice)
406
+ ctx.out(backupNotice);
407
+ if (leftovers.length > 0)
408
+ ctx.out(`Zostawiłem niepuste katalogi: ${leftovers.join(", ")}.`);
409
+ ctx.out("Następny krok: otwórz nowy terminal, żeby zmienna ROBISIE_KEY zniknęła z sesji.");
410
+ return 0;
306
411
  }
307
- catch (error) {
308
- throw new RobisieError(`Nie udało się dokończyć odinstalowania: ${errorMessage(error)}`, `${error instanceof RobisieError ? error.nextStep : "Rozwiąż problem i ponów `npx robisie uninstall`."} ${evidence}`);
412
+ finally {
413
+ let state = "pozostaje";
414
+ try {
415
+ lstatSync(paths.key);
416
+ }
417
+ catch (error) {
418
+ const code = error.code;
419
+ state = code === "ENOENT" ? "nie istnieje" : `nie można ustalić (${typeof code === "string" && /^E[A-Z0-9_]{1,39}$/.test(code) ? code : "błąd metadanych"})`;
420
+ }
421
+ ctx.out(`Lokalny plik klucza: ${state} — ${paths.key}.`);
309
422
  }
310
- const leftovers = [paths.codexRoles, paths.roles, paths.dir].filter((dir) => !removeEmptyDir(dir));
311
- ctx.out(`✓ Usunięto wpisy i bloki robisie oraz cofnięto instalację ról (${manifest.entries.length} zmian).`);
312
- if (backupNotice)
313
- ctx.out(backupNotice);
314
- if (leftovers.length > 0)
315
- ctx.out(`Zostawiłem niepuste katalogi: ${leftovers.join(", ")}.`);
316
- ctx.out("Następny krok: otwórz nowy terminal, żeby zmienna ROBISIE_KEY zniknęła z sesji.");
317
- return 0;
318
423
  }
@@ -0,0 +1,32 @@
1
+ /** Package identity and the server contract it speaks. */
2
+ export declare const PACKAGE_NAME = "robisie";
3
+ export declare const PACKAGE_VERSION = "0.1.1";
4
+ /**
5
+ * MAJOR of the agent-API contract this package understands.
6
+ * PARITY: src/lib/mcp/contract-version.ts (AGENT_API_CONTRACT_VERSION) on the server.
7
+ */
8
+ export declare const SUPPORTED_CONTRACT_MAJOR = 1;
9
+ export declare const DEFAULT_ORIGIN = "https://robisie.app";
10
+ export declare const MCP_SERVER_NAME = "robisie";
11
+ export declare const KEY_ENV = "ROBISIE_KEY";
12
+ /**
13
+ * Local stdio helpers the client roles call (`mcp__client-launch-context__…`, `mcp__ceo-client-launcher__…`).
14
+ * Shipped in `helpers/` (verbatim copies of the repo's scripts/) and started as `npx -y robisie@<version> mcp <name>`.
15
+ */
16
+ export declare const HELPER_SERVERS: readonly ["client-launch-context", "ceo-client-launcher"];
17
+ export type HelperServer = (typeof HELPER_SERVERS)[number];
18
+ /** Environment the helpers read: credential, Builder card, project and installed configuration. */
19
+ export declare const HELPER_ENV: readonly ["ROBISIE_KEY", "ROBISIE_CARD_REF", "ROBISIE_PROJECT_CONTEXT", "XDG_CONFIG_HOME", "CODEX_HOME"];
20
+ export declare function helperCommand(name: HelperServer): {
21
+ command: string;
22
+ args: string[];
23
+ };
24
+ export declare const KEY_PATTERN: RegExp;
25
+ export declare const CONTRACT_VERSION_HEADER = "x-robisie-contract-version";
26
+ /** Text markers delimiting what `init` wrote. `uninstall` removes only what sits between them. */
27
+ export declare const MARK_BEGIN = ">>> robisie (zarz\u0105dzane przez `npx robisie`, nie edytuj) >>>";
28
+ export declare const MARK_END = "<<< robisie <<<";
29
+ /** First-line marker of every role file `init` owns. */
30
+ export declare const FILE_MARK = "robisie:managed";
31
+ export declare function mcpUrl(origin: string): string;
32
+ export declare function wellKnownUrl(origin: string): string;
package/dist/constants.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Package identity and the server contract it speaks. */
2
2
  export const PACKAGE_NAME = "robisie";
3
- export const PACKAGE_VERSION = "0.1.0"; // x-release-please-version
3
+ export const PACKAGE_VERSION = "0.1.1"; // x-release-please-version
4
4
  /**
5
5
  * MAJOR of the agent-API contract this package understands.
6
6
  * PARITY: src/lib/mcp/contract-version.ts (AGENT_API_CONTRACT_VERSION) on the server.
@@ -14,8 +14,8 @@ export const KEY_ENV = "ROBISIE_KEY";
14
14
  * Shipped in `helpers/` (verbatim copies of the repo's scripts/) and started as `npx -y robisie@<version> mcp <name>`.
15
15
  */
16
16
  export const HELPER_SERVERS = ["client-launch-context", "ceo-client-launcher"];
17
- /** Environment the helpers read: credential, the Builder's card and the selected project directory. */
18
- export const HELPER_ENV = [KEY_ENV, "ROBISIE_CARD_REF", "ROBISIE_PROJECT_CONTEXT"];
17
+ /** Environment the helpers read: credential, Builder card, project and installed configuration. */
18
+ export const HELPER_ENV = [KEY_ENV, "ROBISIE_CARD_REF", "ROBISIE_PROJECT_CONTEXT", "XDG_CONFIG_HOME", "CODEX_HOME"];
19
19
  export function helperCommand(name) {
20
20
  return { command: "npx", args: ["-y", `${PACKAGE_NAME}@${PACKAGE_VERSION}`, "mcp", name] };
21
21
  }
@@ -0,0 +1,24 @@
1
+ /** Everything a command touches outside its own logic — injectable, so tests run on a temp HOME. */
2
+ export interface Ctx {
3
+ home: string;
4
+ cwd: string;
5
+ env: Readonly<Record<string, string | undefined>>;
6
+ platform: NodeJS.Platform;
7
+ fetch: typeof fetch;
8
+ out: (line: string) => void;
9
+ err: (line: string) => void;
10
+ /** Interactive hidden prompt; null when there is no terminal to ask on. */
11
+ askSecret: ((question: string) => Promise<string>) | null;
12
+ /** Whole stdin when it is piped (not a terminal); null otherwise. */
13
+ readStdin: (() => Promise<string>) | null;
14
+ origin: string;
15
+ }
16
+ /** A failure the client can act on: what happened + exactly one next step (both in Polish). */
17
+ export declare class RobisieError extends Error {
18
+ readonly nextStep: string;
19
+ constructor(message: string, nextStep: string);
20
+ }
21
+ export declare function errorMessage(error: unknown): string;
22
+ /** One POSIX shell word: single-quoted, each `'` written as `'\''` (paths like `/Users/O'Connor`). */
23
+ export declare function shellQuote(value: string): string;
24
+ export declare function nodeCtx(): Ctx;
package/dist/context.js CHANGED
@@ -29,15 +29,31 @@ function hiddenPrompt(question) {
29
29
  });
30
30
  const rl = createInterface({ input: process.stdin, output, terminal: true });
31
31
  return new Promise((resolve, reject) => {
32
- rl.on("SIGINT", () => {
33
- rl.close();
34
- reject(new RobisieError("Przerwano.", "Uruchom ponownie `npx robisie init`."));
35
- });
36
- rl.question(question, (answer) => {
37
- rl.close();
32
+ let settled = false;
33
+ let inputFailed = false;
34
+ const accept = (answer) => {
38
35
  process.stdout.write("\n");
39
36
  resolve(answer);
37
+ };
38
+ const finish = (answer) => {
39
+ if (settled)
40
+ return;
41
+ settled = true;
42
+ rl.close();
43
+ if (answer !== undefined && !inputFailed)
44
+ return accept(answer);
45
+ reject(new RobisieError("Przerwano.", "Uruchom ponownie `npx robisie init`."));
46
+ };
47
+ const cancel = () => finish();
48
+ rl.on("SIGINT", cancel);
49
+ rl.on("close", cancel);
50
+ rl.on("error", () => {
51
+ // setRawMode can emit EIO synchronously while close is cleaning up a lost PTY.
52
+ inputFailed = true;
53
+ // Let that close finish before cancellation attempts another cleanup.
54
+ queueMicrotask(cancel);
40
55
  });
56
+ rl.question(question, finish);
41
57
  muted = true;
42
58
  });
43
59
  }
@@ -0,0 +1,13 @@
1
+ export type CardLike = {
2
+ seedBriefDigest?: string | null;
3
+ detailsReadError?: string | null;
4
+ id: string;
5
+ status?: string | null;
6
+ details?: Record<string, unknown> | null;
7
+ dependsOn?: string[];
8
+ };
9
+ export declare const HOLD_DETAILS_KEYS: readonly ["reksio_withheld", "hold", "seed_obligation", "seed_brief"];
10
+ export declare const FACTORY_DEPENDENCY_KEYS: readonly ["landed", "test", "review", "maszynista", "reksio_withheld", "hold", "seed_obligation"];
11
+ export declare function factoryDependenciesReady(card: CardLike & {
12
+ depsReady?: boolean;
13
+ }, predecessors?: ReadonlyMap<string, CardLike>): boolean;
@@ -0,0 +1,80 @@
1
+ import { createHash } from "node:crypto";
2
+ export const HOLD_DETAILS_KEYS = ["reksio_withheld", "hold", "seed_obligation", "seed_brief"];
3
+ export const FACTORY_DEPENDENCY_KEYS = ["landed", "test", "review", "maszynista", "reksio_withheld", "hold", "seed_obligation"];
4
+ function briefSha(details, digest) {
5
+ if (digest !== undefined)
6
+ return digest;
7
+ const brief = details?.seed_brief;
8
+ return nonempty(brief) ? createHash("sha256").update(brief).digest("hex") : null;
9
+ }
10
+ function holdActive(details, digest) {
11
+ const hold = details?.hold;
12
+ if (hold === undefined || hold === null)
13
+ return false;
14
+ if (!record(hold) || hold.release !== "packet_changed")
15
+ return true;
16
+ // Zlecenie zasiewu bez potwierdzonego odbioru (pending) trzyma hold niezależnie od briefu: częściowo osadzony albo niezgodny
17
+ // pakiet nie wychodzi do promocji, dopóki most nie zamknie zlecenia po udanym read-backu.
18
+ const obligation = details?.seed_obligation;
19
+ if (record(obligation) && obligation.status === "pending")
20
+ return true;
21
+ const sha = hold.seed_brief_sha256;
22
+ if (sha != null && (typeof sha !== "string" || !/^[0-9a-f]{64}$/.test(sha)))
23
+ return true;
24
+ return (hold.seed_brief_sha256 ?? null) === briefSha(details, digest);
25
+ }
26
+ function draftHeld(details, digest) {
27
+ return details?.reksio_withheld === true || holdActive(details, digest);
28
+ }
29
+ export function factoryDependenciesReady(card, predecessors) {
30
+ if (card.detailsReadError)
31
+ return false;
32
+ if (predecessors === undefined || card.dependsOn === undefined)
33
+ return card.depsReady === true; // legacy callers
34
+ if (!Array.isArray(card.dependsOn))
35
+ return false;
36
+ return card.dependsOn.every(id => {
37
+ const dep = predecessors?.get(id);
38
+ if (!dep || dep.id !== id || dep.detailsReadError)
39
+ return false;
40
+ if (dep.status === "done")
41
+ return true;
42
+ if (dep.status !== "delivered")
43
+ return false;
44
+ const d = dep.details ?? {};
45
+ const land = asRecord(d.landed), test = asRecord(d.test);
46
+ const at = Date.parse(String(test.at)), landedAt = Date.parse(String(land.at));
47
+ if (!landedSha(dep) || test.sha !== land.sha || !["PASS", "PASS_NEEDS_VALIDATOR"].includes(String(test.verdict))
48
+ || !timestamp(test.at) || !timestamp(land.at) || at < landedAt
49
+ || at > Date.now() || draftHeld(d, dep.seedBriefDigest)
50
+ || (d.review != null && !record(d.review)) || (d.maszynista != null && !record(d.maszynista)))
51
+ return false;
52
+ const machine = asRecord(d.maszynista), review = asRecord(d.review);
53
+ // Old failed attempts survive delivery; only a later (or undated) conflict blocks it.
54
+ const newer = (value) => !Number.isFinite(Date.parse(String(value))) || Date.parse(String(value)) >= landedAt;
55
+ if (["worker", "review", "landing", "repair", "blocked"].includes(String(machine.phase)) && newer(machine.at))
56
+ return false;
57
+ if (review.verdict === "FAIL" && newer(review.at))
58
+ return false;
59
+ return true;
60
+ });
61
+ }
62
+ function validTestSha(value) {
63
+ return typeof value === "string" && /^[a-f0-9]{7,40}$/i.test(value);
64
+ }
65
+ function landedSha(card) {
66
+ const landed = card.details?.landed;
67
+ return record(landed) && validTestSha(landed.sha) ? landed.sha : null;
68
+ }
69
+ function record(value) {
70
+ return value !== null && typeof value === "object" && !Array.isArray(value);
71
+ }
72
+ function asRecord(value) {
73
+ return record(value) ? value : {};
74
+ }
75
+ function nonempty(value) {
76
+ return typeof value === "string" && value.trim().length > 0;
77
+ }
78
+ function timestamp(value) {
79
+ return nonempty(value) && Number.isFinite(Date.parse(value));
80
+ }