@agentionai/fieldwork-cli 0.4.0 → 0.8.0

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/dist/main.js CHANGED
@@ -1,97 +1,309 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { Command } from 'commander';
4
- import { request } from './client.js';
5
- import { Scope, setupCampaign } from './workspace.js';
6
- const program = new Command().name('fieldwork').description('Fieldwork API client; JSON results on stdout, JSON failures on stderr').version(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version)
7
- .option('--url <url>', 'API server URL (defaults to FIELDWORK_URL, LAB_URL, workspace config, then loopback)');
4
+ import { request, requestAll } from './client.js';
5
+ import { CLI_VERSION } from './version.js';
6
+ import { DEFAULT_SERVER, Scope, setupCampaign } from './workspace.js';
7
+ import { filterRows, parseCondition, projectRows, resolve, toTsv } from './select.js';
8
+ import { selectChangelog } from './changelog.js';
9
+ import { credentialsPath, forgetCredential, resolveCredential, storeCredential, } from './credentials.js';
10
+ const version = CLI_VERSION;
11
+ const program = new Command()
12
+ .name('fieldwork')
13
+ .description('Fieldwork API client; JSON results on stdout, JSON failures on stderr')
14
+ .version(version)
15
+ .option('--url <url>', `API server URL (defaults to FIELDWORK_URL, LAB_URL, workspace config, then ${DEFAULT_SERVER})`)
16
+ .option('--token <token>', 'API credential (defaults to FIELDWORK_TOKEN)')
17
+ .option('--org <id>', 'Organization to act in (defaults to FIELDWORK_ORGANIZATION); unnecessary for a credential bound to one organization');
8
18
  program.exitOverride();
9
19
  program.configureOutput({ writeErr: () => { } });
10
20
  const output = (value) => console.log(JSON.stringify(value, null, 2));
11
- const send = (path, method, body) => request(url(), path, method, body).then(output);
21
+ /** Credentials resolve like --url does: explicit flag, then environment, then the stored
22
+ * credential for this server. Deliberately no prompt fallback -- a secret typed at a
23
+ * prompt ends up in shell history. */
24
+ const credentials = () => {
25
+ const options = program.opts();
26
+ // serverUrl(), not url(): url() builds a Scope that carries credentials, and asking for
27
+ // credentials to resolve credentials recurses until the stack gives out.
28
+ const { token, organization } = resolveCredential(serverUrl(), {
29
+ token: options.token,
30
+ org: options.org,
31
+ });
32
+ return { token, organization };
33
+ };
34
+ const send = (path, method, body) => request(url(), path, method, body, credentials()).then(output);
12
35
  function input(value) {
13
36
  let parsed;
14
37
  try {
15
38
  parsed = JSON.parse(value === '-' ? readFileSync(0, 'utf8') : value);
16
39
  }
17
40
  catch (error) {
18
- throw Object.assign(new Error(`Cannot read JSON input: ${error.message}`), { code: 'INVALID_JSON', hint: 'Supply a valid JSON object with --json, or use --json - to read it from stdin; quote keys and strings with double quotes.' });
41
+ throw Object.assign(new Error(`Cannot read JSON input: ${error.message}`), {
42
+ code: 'INVALID_JSON',
43
+ hint: 'Supply a valid JSON object with --json, or use --json - to read it from stdin; quote keys and strings with double quotes.',
44
+ });
19
45
  }
20
46
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
21
- throw Object.assign(new Error('JSON input must be an object'), { code: 'INVALID_JSON', hint: 'Use a JSON object such as {"name":"Example"}, not null, an array, or a scalar; see the command’s --help for required fields.' });
47
+ throw Object.assign(new Error('JSON input must be an object'), {
48
+ code: 'INVALID_JSON',
49
+ hint: 'Use a JSON object such as {"name":"Example"}, not null, an array, or a scalar; see the command’s --help for required fields.',
50
+ });
22
51
  return parsed;
23
52
  }
24
- function fail2(message) { throw new Error(message); }
25
- const url = () => new Scope(program.opts().url).url;
53
+ function fail(message) {
54
+ throw new Error(message);
55
+ }
56
+ /** Server resolution alone, with no credentials, so credential resolution can use it. */
57
+ const serverUrl = () => new Scope(program.opts().url).url;
58
+ /** One scope per invocation, carrying both the resolved server and the credentials, so no
59
+ * call site can reach the API without them. */
60
+ const apiScope = () => new Scope(program.opts().url, process.cwd(), credentials());
61
+ const url = () => serverUrl();
26
62
  const path = (id) => `/campaigns/${encodeURIComponent(id)}`;
27
63
  const collection = (product) => product ? `/products/${encodeURIComponent(product)}/campaigns` : '/campaigns';
28
- const setup = program.command('setup').description('Attach the current directory to an existing campaign');
29
- setup.command('campaign').description('Create local config and workspace directories; use --create to add a missing campaign')
64
+ const auth = program.command('auth').description('Manage the API credential for this machine');
65
+ auth
66
+ .command('login')
67
+ .description('Store a credential for this server')
68
+ .action(async () => {
69
+ const options = program.opts();
70
+ // --token and --org are global, so they are spelled the same here as everywhere else.
71
+ // Declaring them again on this subcommand shadowed the global ones and made login the
72
+ // one command that could not see its own credential.
73
+ if (!options.token)
74
+ throw Object.assign(new Error('auth login requires --token'), {
75
+ code: 'MISSING_TOKEN',
76
+ hint: 'Pass the credential with --token. It is stored for this server only, under your config directory.',
77
+ });
78
+ const server = url();
79
+ // Verified before it is written. Storing an unusable credential turns a typo into a
80
+ // failure on some later command, far from the paste that caused it.
81
+ const organizations = (await request(server, '/organizations', 'GET', undefined, {
82
+ token: options.token,
83
+ organization: options.org,
84
+ }));
85
+ const chosen = options.org ?? (organizations.length === 1 ? organizations[0]?.id : undefined);
86
+ const path = storeCredential(server, {
87
+ token: options.token,
88
+ ...(chosen ? { organization: chosen } : {}),
89
+ });
90
+ output({
91
+ server,
92
+ storedAt: path,
93
+ organizations,
94
+ organization: chosen ?? null,
95
+ // Several organizations and no --org means later commands need one; say so now
96
+ // rather than letting every request fail with a permission error.
97
+ ...(chosen
98
+ ? {}
99
+ : {
100
+ note: 'Several organizations are available; pass --org, or set FIELDWORK_ORGANIZATION.',
101
+ }),
102
+ });
103
+ });
104
+ auth
105
+ .command('status')
106
+ .description('Show which credential this server would use, and whether it works')
107
+ .action(async () => {
108
+ const server = url();
109
+ const resolved = resolveCredential(server, program.opts());
110
+ if (!resolved.token)
111
+ return output({
112
+ server,
113
+ source: 'none',
114
+ credentialsFile: credentialsPath(),
115
+ note: 'No credential. Use fieldwork auth login --token, or set FIELDWORK_TOKEN.',
116
+ });
117
+ const organizations = (await request(server, '/organizations', 'GET', undefined, resolved));
118
+ output({
119
+ server,
120
+ // Which source is in play matters: a stale FIELDWORK_TOKEN silently shadowing a
121
+ // fresh login is the usual confusion.
122
+ source: resolved.source,
123
+ credentialsFile: credentialsPath(),
124
+ organization: resolved.organization ?? null,
125
+ organizations,
126
+ });
127
+ });
128
+ auth
129
+ .command('logout')
130
+ .description('Remove the stored credential for this server')
131
+ .action(() => {
132
+ const server = url();
133
+ // Reports what it did rather than failing when there was nothing stored: logging out
134
+ // twice is not an error, and an environment variable is not ours to unset.
135
+ output({ server, removed: forgetCredential(server), credentialsFile: credentialsPath() });
136
+ });
137
+ const setup = program
138
+ .command('setup')
139
+ .description('Attach the current directory to an existing campaign');
140
+ setup
141
+ .command('campaign')
142
+ .description('Create local config and workspace directories; use --create to add a missing campaign')
30
143
  .requiredOption('--campaign <ref>', 'Campaign stub or ID')
31
144
  .option('--product <ref>', 'Product stub or ID; validates parentage')
32
145
  .option('--create', 'Create the campaign when not found; requires --goal')
33
146
  .option('--goal <text>', 'Goal for --create')
34
147
  .action(async (options) => {
35
- const result = await setupCampaign(url(), { ...options, campaign: options.campaign }, process.cwd());
36
- output({ workspaceRoot: result.root, created: result.created, product: result.config.product, campaign: result.config.campaign });
148
+ const result = await setupCampaign(url(), { ...options, campaign: options.campaign }, process.cwd(), credentials());
149
+ output({
150
+ workspaceRoot: result.root,
151
+ created: result.created,
152
+ product: result.config.product,
153
+ campaign: result.config.campaign,
154
+ });
37
155
  });
38
- program.command('context').description('Effective server, workspace scope, and live inherited research context (setup campaign required when no scope is given)')
156
+ program
157
+ .command('context')
158
+ .description('Effective server, workspace scope, and live inherited research context (setup campaign required when no scope is given)')
39
159
  .option('--campaign <ref>', 'Inspect without a local workspace')
40
- .action(async (options) => output(await new Scope(url()).context(options.campaign)));
41
- const campaigns = program.command('campaigns').description('Manage research goals and inspect inherited product context');
42
- campaigns.command('list').option('--product <ref>', 'Scope to product (stub or ID) via /products/:id/campaigns').description('List campaigns; omit product for the whole workspace').action(async (options) => send(collection(options.product && (await new Scope(url()).product(options.product)).id)));
43
- campaigns.command('get').argument('<ref>').description('Get campaign by stub or ID, with live parent context and runs').action(async (reference) => send(path((await new Scope(url()).campaign(reference)).id)));
44
- campaigns.command('context').argument('<ref>').description('Get campaign goal, criteria, constraints and inherited context (same complete response as get)').action(async (reference) => send(path((await new Scope(url()).campaign(reference)).id)));
45
- campaigns.command('create').option('--product <ref>', 'Product (stub or ID) in the URL; omit for independent campaigns or a productId in JSON').requiredOption('--json <json|->', 'JSON object, or - to read stdin; requires name and goal')
46
- .action(async (options) => send(collection(options.product && (await new Scope(url()).product(options.product)).id), 'POST', input(options.json)));
47
- campaigns.command('update').argument('<ref>').requiredOption('--json <json|->', 'Patch including expected revision').description('Patch by stub or ID; JSON includes expected revision').action(async (reference, options) => send(path((await new Scope(url()).campaign(reference)).id), 'PATCH', input(options.json)));
48
- campaigns.command('delete').argument('<ref>').requiredOption('--revision <number>', 'Expected revision; deletion is blocked by experiments or runs')
160
+ .action(async (options) => output(await apiScope().context(options.campaign)));
161
+ program
162
+ .command('changelog')
163
+ .description('Recent releases of this CLI, offline; each change states whether it needs an updated API')
164
+ .option('--since <version>', 'Only releases after this version')
165
+ .option('--release <version>', 'Only this release')
166
+ .addHelpText('after', '\nThe package carries recent releases only; see the repository changelog for the full history.\nrequiresApi records what a change needs from the server. The CLI cannot verify what an API\nprovides, and installing a newer client never upgrades a server.')
167
+ .action((options) => output(selectChangelog(version, options)));
168
+ const campaigns = program
169
+ .command('campaigns')
170
+ .description('Manage research goals and inspect inherited product context');
171
+ campaigns
172
+ .command('list')
173
+ .option('--product <ref>', 'Scope to product (stub or ID) via /products/:id/campaigns')
174
+ .description('List campaigns; omit product for the whole workspace')
175
+ .action(async (options) => send(collection(options.product && (await apiScope().product(options.product)).id)));
176
+ campaigns
177
+ .command('get')
178
+ .argument('<ref>')
179
+ .description('Get campaign by stub or ID, with live parent context and runs')
180
+ .action(async (reference) => send(path((await apiScope().campaign(reference)).id)));
181
+ campaigns
182
+ .command('context')
183
+ .argument('<ref>')
184
+ .description('Get campaign goal, criteria, constraints and inherited context (same complete response as get)')
185
+ .action(async (reference) => send(path((await apiScope().campaign(reference)).id)));
186
+ campaigns
187
+ .command('create')
188
+ .option('--product <ref>', 'Product (stub or ID) in the URL; omit for independent campaigns or a productId in JSON')
189
+ .requiredOption('--json <json|->', 'JSON object, or - to read stdin; requires name and goal')
190
+ .action(async (options) => send(collection(options.product && (await apiScope().product(options.product)).id), 'POST', input(options.json)));
191
+ campaigns
192
+ .command('update')
193
+ .argument('<ref>')
194
+ .requiredOption('--json <json|->', 'Patch including expected revision')
195
+ .description('Patch by stub or ID; JSON includes expected revision')
196
+ .action(async (reference, options) => send(path((await apiScope().campaign(reference)).id), 'PATCH', input(options.json)));
197
+ campaigns
198
+ .command('delete')
199
+ .argument('<ref>')
200
+ .requiredOption('--revision <number>', 'Expected revision')
201
+ .option('--confirm <name>', 'The campaign name, required when it holds work: everything in it is deleted too')
202
+ .description('Delete a campaign and everything in it, for good. To keep the work out of the way instead, use archive')
49
203
  .action(async (reference, options) => {
50
204
  const revision = Number(options.revision);
51
205
  if (!Number.isSafeInteger(revision) || revision < 1)
52
206
  throw new Error('Revision must be a positive integer');
53
- return send(path((await new Scope(url()).campaign(reference)).id), 'DELETE', { revision });
207
+ return send(path((await apiScope().campaign(reference)).id), 'DELETE', {
208
+ revision,
209
+ ...(options.confirm ? { confirm: options.confirm } : {}),
210
+ });
54
211
  });
55
- const artifacts = program.command('artifacts').description('Product-scoped recipes and file metadata; permanently immutable after reference');
56
- const artifactProduct = async (ref) => (await new Scope(url()).product(ref)).id;
212
+ const artifacts = program
213
+ .command('artifacts')
214
+ .description('Product-scoped recipes and file metadata; permanently immutable after reference');
215
+ const artifactProduct = async (ref) => (await apiScope().product(ref)).id;
57
216
  async function artifactRef(ref, product) {
58
217
  if (/^[0-9a-f-]{36}$/i.test(ref) && !product)
59
218
  return ref;
60
219
  if (!product)
61
- fail2('Artifact stubs require --product REF; IDs can be used directly');
62
- const values = await request(url(), `/products/${encodeURIComponent(await artifactProduct(product))}/artifacts`);
220
+ fail('Artifact stubs require --product REF; IDs can be used directly');
221
+ const values = (await request(url(), `/products/${encodeURIComponent(await artifactProduct(product))}/artifacts`, 'GET', undefined, credentials()));
63
222
  const value = values.find((v) => v.id === ref || v.stub === ref);
64
223
  if (!value)
65
- fail2('Artifact not found in product');
224
+ fail('Artifact not found in product');
66
225
  return value.id;
67
226
  }
68
- artifacts.command('list').requiredOption('--product <ref>', 'Product stub or ID').action(async (o) => send(`/products/${encodeURIComponent(await artifactProduct(o.product))}/artifacts`));
69
- artifacts.command('create').requiredOption('--product <ref>', 'Product stub or ID').requiredOption('--json <json|->', 'Requires stub/name; optional kind, description, definition object, files [{role,uri,sha256?}], derivedFrom ID').action(async (o) => send(`/products/${encodeURIComponent(await artifactProduct(o.product))}/artifacts`, 'POST', input(o.json)));
70
- artifacts.command('get').argument('<ref>').option('--product <ref>', 'Required for artifact stubs').action(async (ref, o) => send(`/artifacts/${encodeURIComponent(await artifactRef(ref, o.product))}`));
71
- artifacts.command('update').argument('<ref>').option('--product <ref>', 'Required for artifact stubs').requiredOption('--json <json|->', 'Patch with current revision; definition/files replace whole fields; frozen artifacts reject edits').action(async (ref, o) => send(`/artifacts/${encodeURIComponent(await artifactRef(ref, o.product))}`, 'PATCH', input(o.json)));
72
- artifacts.command('delete').argument('<ref>').option('--product <ref>', 'Required for artifact stubs').requiredOption('--revision <number>', 'Expected revision; referenced artifacts cannot be deleted').action(async (ref, o) => { const revision = Number(o.revision); if (!Number.isSafeInteger(revision) || revision < 1)
73
- fail2('Revision must be a positive integer'); return send(`/artifacts/${encodeURIComponent(await artifactRef(ref, o.product))}`, 'DELETE', { revision }); });
74
- artifacts.command('diff').argument('<from>').argument('<to>').option('--product <ref>', 'Required for artifact stubs').description('Compare recipe definitions/files within one product; paths use JSON Pointer').action(async (from, to, o) => send(`/artifacts/${encodeURIComponent(await artifactRef(from, o.product))}/diff/${encodeURIComponent(await artifactRef(to, o.product))}`));
75
- const resolveSchema = async (ref) => { if (!/^[0-9a-f-]{36}$/i.test(ref))
76
- fail2('Schema references are version IDs; use schemas list --product REF to obtain one'); return ref; };
77
- const schemas = program.command('schemas').description('Manage typed research schemas: publish immutable versions, inspect templates, and validate payloads before writing records');
227
+ artifacts
228
+ .command('list')
229
+ .requiredOption('--product <ref>', 'Product stub or ID')
230
+ .action(async (o) => send(`/products/${encodeURIComponent(await artifactProduct(o.product))}/artifacts`));
231
+ artifacts
232
+ .command('create')
233
+ .requiredOption('--product <ref>', 'Product stub or ID')
234
+ .requiredOption('--json <json|->', 'Requires stub/name; optional kind, description, definition object, files [{role,uri,sha256?}], derivedFrom ID')
235
+ .action(async (o) => send(`/products/${encodeURIComponent(await artifactProduct(o.product))}/artifacts`, 'POST', input(o.json)));
236
+ artifacts
237
+ .command('get')
238
+ .argument('<ref>')
239
+ .option('--product <ref>', 'Required for artifact stubs')
240
+ .action(async (ref, o) => send(`/artifacts/${encodeURIComponent(await artifactRef(ref, o.product))}`));
241
+ artifacts
242
+ .command('update')
243
+ .argument('<ref>')
244
+ .option('--product <ref>', 'Required for artifact stubs')
245
+ .requiredOption('--json <json|->', 'Patch with current revision; definition/files replace whole fields; frozen artifacts reject edits')
246
+ .action(async (ref, o) => send(`/artifacts/${encodeURIComponent(await artifactRef(ref, o.product))}`, 'PATCH', input(o.json)));
247
+ artifacts
248
+ .command('delete')
249
+ .argument('<ref>')
250
+ .option('--product <ref>', 'Required for artifact stubs')
251
+ .requiredOption('--revision <number>', 'Expected revision; referenced artifacts cannot be deleted')
252
+ .action(async (ref, o) => {
253
+ const revision = Number(o.revision);
254
+ if (!Number.isSafeInteger(revision) || revision < 1)
255
+ fail('Revision must be a positive integer');
256
+ return send(`/artifacts/${encodeURIComponent(await artifactRef(ref, o.product))}`, 'DELETE', {
257
+ revision,
258
+ });
259
+ });
260
+ artifacts
261
+ .command('diff')
262
+ .argument('<from>')
263
+ .argument('<to>')
264
+ .option('--product <ref>', 'Required for artifact stubs')
265
+ .description('Compare recipe definitions/files within one product; paths use JSON Pointer')
266
+ .action(async (from, to, o) => send(`/artifacts/${encodeURIComponent(await artifactRef(from, o.product))}/diff/${encodeURIComponent(await artifactRef(to, o.product))}`));
267
+ const resolveSchema = async (ref) => {
268
+ if (!/^[0-9a-f-]{36}$/i.test(ref))
269
+ fail('Schema references are version IDs; use schemas list --product REF to obtain one');
270
+ return ref;
271
+ };
272
+ const schemas = program
273
+ .command('schemas')
274
+ .description('Manage typed research schemas: publish immutable versions, inspect templates, and validate payloads before writing records');
78
275
  async function schemaScope(options) {
79
- const scope = new Scope(url());
276
+ const scope = apiScope();
80
277
  if (options.product) {
81
278
  if (options.campaign || options.experiment)
82
- fail2('--product cannot be combined with --campaign or --experiment for schemas');
279
+ fail('--product cannot be combined with --campaign or --experiment for schemas');
83
280
  return `/products/${encodeURIComponent((await scope.product(options.product)).id)}`;
84
281
  }
85
282
  const campaign = await scope.campaign(options.campaign);
86
- return options.experiment ? `/experiments/${encodeURIComponent(await scope.work('experiments', options.experiment, campaign.id))}` : path(campaign.id);
283
+ return options.experiment
284
+ ? `/experiments/${encodeURIComponent(await scope.work('experiments', options.experiment, campaign.id))}`
285
+ : path(campaign.id);
87
286
  }
88
287
  for (const operation of ['list', 'publish', 'default', 'set-default']) {
89
- const command = schemas.command(operation).option('--product <ref>', 'Product scope; cannot combine with campaign/experiment').option('--campaign <ref>', 'Campaign scope; defaults to workspace').option('--experiment <ref>', 'Experiment within campaign');
288
+ const command = schemas
289
+ .command(operation)
290
+ .option('--product <ref>', 'Product scope; cannot combine with campaign/experiment')
291
+ .option('--campaign <ref>', 'Campaign scope; defaults to workspace')
292
+ .option('--experiment <ref>', 'Experiment within campaign');
90
293
  if (operation === 'list')
91
294
  command.option('--inherited', 'Include schemas owned by ancestors');
92
295
  if (operation === 'publish' || operation === 'set-default')
93
- command.requiredOption('--json <json|->', operation === 'publish' ? 'JSON with stub, version, definition; - reads stdin' : 'JSON with schemaVersionId and current default revision; null clears product/campaign default');
94
- command.description(operation === 'default' ? 'Inspect local and effective schema default; no changes to existing records' : operation === 'set-default' ? 'Set a product/campaign default or pin an experiment (immutable once runs exist)' : operation === 'list' ? 'List local schemas; --inherited includes ancestors' : 'Publish an immutable schema owned by product, campaign or experiment')
296
+ command.requiredOption('--json <json|->', operation === 'publish'
297
+ ? 'JSON with stub, version, definition; - reads stdin'
298
+ : 'JSON with schemaVersionId and current default revision; null clears product/campaign default');
299
+ command
300
+ .description(operation === 'default'
301
+ ? 'Inspect local and effective schema default; no changes to existing records'
302
+ : operation === 'set-default'
303
+ ? 'Set a product/campaign default or pin an experiment (immutable once runs exist)'
304
+ : operation === 'list'
305
+ ? 'List local schemas; --inherited includes ancestors'
306
+ : 'Publish an immutable schema owned by product, campaign or experiment')
95
307
  .action(async (options) => {
96
308
  const target = await schemaScope(options);
97
309
  if (operation === 'set-default')
@@ -100,105 +312,329 @@ for (const operation of ['list', 'publish', 'default', 'set-default']) {
100
312
  });
101
313
  }
102
314
  for (const operation of ['get', 'template'])
103
- schemas.command(operation).argument('<ref>').description(operation === 'template' ? 'Illustrative experiment and observation placeholders; replace, never record as measured values' : 'Published schema definition').action(async (ref) => send(`/schemas/${encodeURIComponent(await resolveSchema(ref))}${operation === 'template' ? '/template' : ''}`));
104
- schemas.command('extend').argument('<ref>').requiredOption('--json <json|->', 'Complete replacement definition: {definition:{parameters,observations,comparisonContext}}').option('--dry-run', 'Validate and preview affected records without saving').description('Publish a compatible successor and atomically re-pin matching experiments, runs, defaults and charts; old versions and snapshots remain intact')
105
- .action(async (ref, options) => { const data = input(options.json); if (options.dryRun)
106
- data.dryRun = true; return send(`/schemas/${encodeURIComponent(await resolveSchema(ref))}/extend`, 'POST', data); });
107
- schemas.command('validate').argument('<ref>').requiredOption('--json <json|->', 'Payload with parameters/observations/comparisonContext').option('--ready', 'Treat required parameters as blocking rather than advisory').description('Check values without saving; exit code 1 with VALIDATION_FAILED when the payload does not satisfy the schema')
315
+ schemas
316
+ .command(operation)
317
+ .argument('<ref>')
318
+ .description(operation === 'template'
319
+ ? 'Illustrative experiment and observation placeholders; replace, never record as measured values'
320
+ : 'Published schema definition')
321
+ .action(async (ref) => send(`/schemas/${encodeURIComponent(await resolveSchema(ref))}${operation === 'template' ? '/template' : ''}`));
322
+ schemas
323
+ .command('extend')
324
+ .argument('<ref>')
325
+ .requiredOption('--json <json|->', 'Complete replacement definition: {definition:{parameters,observations,comparisonContext}}; optional backfill:{field:{RUN_ID:value}} for fields a run predates')
326
+ .option('--dry-run', 'Validate and preview affected records without saving')
327
+ .description('Publish a compatible successor and atomically re-pin matching experiments, runs, defaults and charts; old versions and snapshots remain intact')
328
+ .addHelpText('after', '\nbackfill fills in parameter or context fields runs left empty -- added by this extension,\nor there all along and forgotten -- on runs this extension re-pins, finished runs included, e.g.\n {"definition":{...},"backfill":{"packager":{"RUN_ID":"unsloth"}}}\nName a field parameters.<field> or comparisonContext.<field> if the bare name is\nambiguous. Never over a recorded value; values are validated with the extension. Runs note\nthem in research.backfilled with when and by whom, and a backfilled value stays correctable\nwith runs update. For a field the schema already has, runs backfill needs no extension.\nAdding enum values is an ordinary extension.')
329
+ .action(async (ref, options) => {
330
+ const data = input(options.json);
331
+ if (options.dryRun)
332
+ data.dryRun = true;
333
+ return send(`/schemas/${encodeURIComponent(await resolveSchema(ref))}/extend`, 'POST', data);
334
+ });
335
+ schemas
336
+ .command('validate')
337
+ .argument('<ref>')
338
+ .requiredOption('--json <json|->', 'Payload with parameters/observations/comparisonContext')
339
+ .option('--ready', 'Treat required parameters as blocking rather than advisory')
340
+ .description('Check values without saving; exit code 1 with VALIDATION_FAILED when the payload does not satisfy the schema')
108
341
  .action(async (ref, options) => {
109
342
  const data = input(options.json);
110
343
  if (!data || typeof data !== 'object' || Array.isArray(data))
111
- fail2('Validation input must be a JSON object');
344
+ fail('Validation input must be a JSON object');
112
345
  if (options.ready)
113
346
  data.ready = true;
114
- const result = await request(url(), `/schemas/${encodeURIComponent(await resolveSchema(ref))}/validate`, 'POST', data);
347
+ const result = (await request(url(), `/schemas/${encodeURIComponent(await resolveSchema(ref))}/validate`, 'POST', data, credentials()));
115
348
  output(result);
116
349
  if (result.valid === false) {
117
- console.error(JSON.stringify({ code: 'VALIDATION_FAILED', message: 'Payload does not satisfy the schema; see stdout for the full report', hint: 'Repair stdout issues using their paths, expected values and hints, then rerun validation before writing.' }));
350
+ console.error(JSON.stringify({
351
+ code: 'VALIDATION_FAILED',
352
+ message: 'Payload does not satisfy the schema; see stdout for the full report',
353
+ hint: 'Repair stdout issues using their paths, expected values and hints, then rerun validation before writing.',
354
+ }));
118
355
  process.exitCode = 1;
119
356
  }
120
357
  });
121
- const charts = program.command('charts').description('Create saved typed bar, line and scatter charts for campaigns or experiments');
358
+ const charts = program
359
+ .command('charts')
360
+ .description('Create saved typed bar, line and scatter charts for campaigns or experiments');
122
361
  for (const operation of ['list', 'fields', 'create']) {
123
- const command = charts.command(operation).option('--campaign <ref>', 'Campaign stub or ID; defaults to workspace').option('--experiment <ref>', 'Experiment stub or ID within campaign');
362
+ const command = charts
363
+ .command(operation)
364
+ .option('--campaign <ref>', 'Campaign stub or ID; defaults to workspace')
365
+ .option('--experiment <ref>', 'Experiment stub or ID within campaign');
124
366
  if (operation === 'create')
125
367
  command.requiredOption('--json <json|->', 'Object with title, type (bar/line/scatter), schemaVersionId, x and y: {section,field,label?}; - reads stdin');
126
368
  if (operation === 'create')
127
369
  command.addHelpText('after', '\nOptional aggregation: {groupBy:[{section,field}],metric:"mean"|"stdev",spread:"none"|"whiskers"|"band"}. Groups retain X and context boundaries; sample SD uses n-1 (unavailable for n<2). Spread requires mean; bands require line. No arbitrary expressions.');
128
- command.description(operation === 'fields' ? 'Discover scoped schema versions, typed fields and supported chart types' : operation === 'list' ? 'List saved chart definitions' : 'Save an immutable chart definition; live successful runs, optionally grouped with mean or sample stdev')
370
+ command
371
+ .description(operation === 'fields'
372
+ ? 'Discover scoped schema versions, typed fields and supported chart types'
373
+ : operation === 'list'
374
+ ? 'List saved chart definitions'
375
+ : 'Save an immutable chart definition; live successful runs, optionally grouped with mean or sample stdev')
129
376
  .action(async (options) => {
130
- const scope = new Scope(url());
377
+ const scope = apiScope();
131
378
  const campaign = await scope.campaign(options.campaign);
132
- const target = options.experiment ? `/experiments/${encodeURIComponent(await scope.work('experiments', options.experiment, campaign.id))}` : path(campaign.id);
379
+ const target = options.experiment
380
+ ? `/experiments/${encodeURIComponent(await scope.work('experiments', options.experiment, campaign.id))}`
381
+ : path(campaign.id);
133
382
  return send(`${target}/charts${operation === 'fields' ? '/fields' : ''}`, operation === 'create' ? 'POST' : 'GET', operation === 'create' ? input(options.json) : undefined);
134
383
  });
135
384
  }
136
385
  for (const operation of ['get', 'data', 'delete'])
137
- charts.command(operation).argument('<id>', 'Chart ID returned by create/list').description(operation === 'data' ? 'Live points with source run IDs/revisions, context series, exclusions and timestamp' : operation === 'delete' ? 'Delete a saved chart definition, never its source runs' : 'Get a saved chart definition').action((id) => send(`/charts/${encodeURIComponent(id)}${operation === 'data' ? '/data' : ''}`, operation === 'delete' ? 'DELETE' : 'GET'));
386
+ charts
387
+ .command(operation)
388
+ .argument('<id>', 'Chart ID returned by create/list')
389
+ .description(operation === 'data'
390
+ ? 'Live points with source run IDs/revisions, context series, exclusions and timestamp'
391
+ : operation === 'delete'
392
+ ? 'Delete a saved chart definition, never its source runs'
393
+ : 'Get a saved chart definition')
394
+ .action((id) => send(`/charts/${encodeURIComponent(id)}${operation === 'data' ? '/data' : ''}`, operation === 'delete' ? 'DELETE' : 'GET'));
395
+ charts
396
+ .command('series')
397
+ .argument('<id>', 'Chart ID returned by create/list')
398
+ .requiredOption('--json <json|->', 'Object with series: {by:"field",section,field}, {by:"groups",groups:[{label,runIds}],otherLabel?}, or null')
399
+ .description("Choose what a raw chart's colours mean: a typed field, groups of runs you define, or null for comparison context")
400
+ .addHelpText('after', '\nBy field colours each point by a parameter or comparison-context value, e.g.\n {"series":{"by":"field","section":"parameters","field":"recipe_family"}}\nBy groups names the split no single field records, e.g. ours against as shipped:\n {"series":{"by":"groups","groups":[{"label":"Ours","runIds":["RUN_ID"]}],"otherLabel":"Shipped"}}\nOnly runs the chart plots, each in one group; the rest join otherLabel. Each point\nstill reports its comparison context. Not for aggregated charts. Also accepted by create.')
401
+ .action((id, options) => send(`/charts/${encodeURIComponent(id)}/series`, 'PUT', input(options.json)));
402
+ charts
403
+ .command('frontier')
404
+ .argument('<id>', 'Chart ID returned by create/list')
405
+ .requiredOption('--json <json|->', 'Object with runIds: the run IDs to join, replacing any earlier choice; [] clears it')
406
+ .description('Choose the runs a scatter chart joins as its frontier; only runs it plots, never computed')
407
+ .addHelpText('after', '\nA frontier is a judgement, not a calculation: pick the runs that are comparable and\nrepresent the trade-off. Scatter charts without aggregation only. The line is drawn\nthrough the chosen runs in X order.')
408
+ .action((id, options) => send(`/charts/${encodeURIComponent(id)}/frontier`, 'PUT', input(options.json)));
409
+ program
410
+ .command('whoami')
411
+ .description('Who this credential is, the organization it acts in, and the permissions it currently holds')
412
+ .addHelpText('after', '\nPermissions are derived per request, so this is what a write would actually be allowed\nto do -- not a profile name or a role. An agent credential also reports itself, since\nnothing else tells it which agent it is or whose work it will be attributed to.')
413
+ .action(() => send('/me'));
138
414
  const products = program.command('products').description('Manage products');
139
415
  products.command('list').action(() => send('/products'));
140
- products.command('get').argument('<ref>').description('Get a product by stub or ID').action(async (reference) => send(`/products/${encodeURIComponent((await new Scope(url()).product(reference)).id)}`));
141
- products.command('create').requiredOption('--json <json|->', 'JSON with name; - reads stdin').action((options) => send('/products', 'POST', input(options.json)));
142
- products.command('update').argument('<ref>').requiredOption('--json <json|->', 'Patch with expected revision').action(async (reference, options) => send(`/products/${encodeURIComponent((await new Scope(url()).product(reference)).id)}`, 'PATCH', input(options.json)));
143
- products.command('delete').argument('<ref>').description('Delete an empty product by stub or ID; existing product API does not require a revision').action(async (reference) => send(`/products/${encodeURIComponent((await new Scope(url()).product(reference)).id)}`, 'DELETE'));
416
+ products
417
+ .command('get')
418
+ .argument('<ref>')
419
+ .description('Get a product by stub or ID')
420
+ .action(async (reference) => send(`/products/${encodeURIComponent((await apiScope().product(reference)).id)}`));
421
+ products
422
+ .command('create')
423
+ .requiredOption('--json <json|->', 'JSON with name; - reads stdin')
424
+ .action((options) => send('/products', 'POST', input(options.json)));
425
+ products
426
+ .command('update')
427
+ .argument('<ref>')
428
+ .requiredOption('--json <json|->', 'Patch with expected revision')
429
+ .action(async (reference, options) => send(`/products/${encodeURIComponent((await apiScope().product(reference)).id)}`, 'PATCH', input(options.json)));
430
+ products
431
+ .command('delete')
432
+ .argument('<ref>')
433
+ .option('--confirm <name>', 'The product name, required when it holds work: its campaigns and everything in them go too')
434
+ .description('Delete a product and everything in it, for good. To keep the work out of the way instead, use archive')
435
+ .action(async (reference, options) => send(`/products/${encodeURIComponent((await apiScope().product(reference)).id)}`, 'DELETE', options.confirm ? { confirm: options.confirm } : undefined));
144
436
  for (const kind of ['experiments', 'runs']) {
145
- const group = program.command(kind).description(`Manage ${kind}; status updates record state, never launch or stop jobs`);
146
- const resolveRef = (reference, campaignRef) => new Scope(url()).work(kind, reference, campaignRef);
147
- group.command('list').option('--campaign <ref>', 'Defaults to the local workspace campaign').action(async (options) => send(`${path((await new Scope(url()).campaign(options.campaign)).id)}/${kind}`));
437
+ const group = program
438
+ .command(kind)
439
+ .description(`Manage ${kind}; status updates record state, never launch or stop jobs`);
440
+ const resolveRef = (reference, campaignRef) => apiScope().work(kind, reference, campaignRef);
441
+ const list = group
442
+ .command('list')
443
+ .option('--campaign <ref>', 'Defaults to the local workspace campaign')
444
+ .option('--where <expression>', 'Keep records matching FIELD=VALUE, or != >= <= > <; repeatable and combined with AND', (value, previous) => [...previous, value], [])
445
+ .option('--fields <paths>', 'Comma-separated paths to print instead of whole records, such as parameters.n,observations.accuracy')
446
+ .option('--format <format>', 'json (default) or tsv', 'json')
447
+ .option('--include-superseded', 'Include records whose extras.superseded_by names a replacement')
448
+ .option('--include-abandoned', kind === 'runs' ? 'Include runs of abandoned experiments' : 'Include abandoned experiments');
449
+ if (kind === 'runs')
450
+ list.option('--experiment <ref>', 'Only runs of this experiment, by stub or ID');
451
+ else
452
+ list.option('--include-archived', 'Include archived experiments');
453
+ list
454
+ .description(`List ${kind}; superseded records, abandoned and archived work are excluded unless asked for`)
455
+ .action(async (options) => {
456
+ if (!['json', 'tsv'].includes(options.format))
457
+ fail('--format must be json or tsv');
458
+ const scope = apiScope();
459
+ const campaign = await scope.campaign(options.campaign);
460
+ let rows = await requestAll(url(), `${path(campaign.id)}/${kind}${options.includeArchived ? '?archived=include' : ''}`, credentials());
461
+ if (options.experiment) {
462
+ const experimentId = await scope.work('experiments', options.experiment, campaign.id);
463
+ rows = rows.filter((row) => row['experimentId'] === experimentId);
464
+ }
465
+ if (!options.includeSuperseded)
466
+ rows = rows.filter((row) => resolve(kind, row, 'extras.superseded_by') == null);
467
+ if (!options.includeAbandoned) {
468
+ if (kind === 'experiments')
469
+ rows = rows.filter((row) => row['status'] !== 'abandoned');
470
+ else {
471
+ const abandoned = new Set((await requestAll(url(), `${path(campaign.id)}/experiments`, credentials()))
472
+ .filter((e) => e['status'] === 'abandoned')
473
+ .map((e) => e['id']));
474
+ rows = rows.filter((row) => !abandoned.has(row['experimentId']));
475
+ }
476
+ }
477
+ rows = filterRows(kind, rows, options.where.map(parseCondition));
478
+ const fields = options.fields
479
+ ? options.fields
480
+ .split(',')
481
+ .map((field) => field.trim())
482
+ .filter(Boolean)
483
+ : [];
484
+ if (fields.length)
485
+ rows = projectRows(kind, rows, fields);
486
+ return options.format === 'tsv' ? console.log(toTsv(rows, fields)) : output(rows);
487
+ });
488
+ if (kind === 'runs')
489
+ group
490
+ .command('record')
491
+ .argument('<ref>')
492
+ .option('--campaign <ref>', 'Defaults to the local workspace campaign')
493
+ .requiredOption('--json <json|->', 'Object with observations and/or a status; optional errorSummary, extras, extrasMode (replace|merge), comparisonContext, allowIncompleteComparisonContext, artifacts, startedAt, finishedAt, revision')
494
+ .description('Record an outcome in one call: the server reads the current revision, and a result may be recorded directly from planned. Summary observations, not logs: link those with logsUri')
495
+ .action(async (reference, options) => send(`/runs/${encodeURIComponent(await resolveRef(reference, options.campaign))}/record`, 'POST', input(options.json)));
496
+ if (kind === 'runs')
497
+ group
498
+ .command('backfill')
499
+ .argument('<ref>')
500
+ .option('--campaign <ref>', 'Defaults to the local workspace campaign')
501
+ .requiredOption('--json <json|->', 'Object with parameters and/or comparisonContext: the fields to fill in, e.g. {"comparisonContext":{"git_commit":"a1b2c3d"}}')
502
+ .description('Fill in a parameter or comparison-context field a run left empty, finished runs included. Never over a recorded value; noted as added later, with when and by whom. A field the schema lacks is added with schemas extend.')
503
+ .action(async (reference, options) => send(`/runs/${encodeURIComponent(await resolveRef(reference, options.campaign))}/backfill`, 'POST', input(options.json)));
148
504
  for (const operation of ['get', 'context'])
149
- group.command(operation).argument('<ref>').option('--campaign <ref>', 'Defaults to the local workspace campaign').description('Record by stub or ID with live parent context' + (kind === 'runs' ? ' and creation-time snapshot' : ' and child runs')).action(async (reference, options) => send(`/${kind}/${encodeURIComponent(await resolveRef(reference, options.campaign))}`));
150
- const create = group.command('create').option('--campaign <ref>', 'Campaign stub or ID; defaults to the local workspace campaign');
505
+ group
506
+ .command(operation)
507
+ .argument('<ref>')
508
+ .option('--campaign <ref>', 'Defaults to the local workspace campaign')
509
+ .description('Record by stub or ID with live parent context' +
510
+ (kind === 'runs' ? ' and creation-time snapshot' : ' and child runs'))
511
+ .action(async (reference, options) => send(`/${kind}/${encodeURIComponent(await resolveRef(reference, options.campaign))}`));
512
+ const create = group
513
+ .command('create')
514
+ .option('--campaign <ref>', 'Campaign stub or ID; defaults to the local workspace campaign');
151
515
  if (kind === 'runs')
152
516
  create.option('--experiment <ref>', 'Experiment stub or ID in campaign scope; cannot combine with JSON experimentId');
153
- create.description(kind === 'runs' ? 'Create a planned run record; this does not execute a job' : 'Create an experiment with a hypothesis and optional schema version').requiredOption('--json <json|->', kind === 'runs' ? 'Object requiring title; optional config, observations, comparisonContext, extras; - reads stdin' : 'Object requiring name and hypothesis; optional parameters, schemaVersionId, varying, comparisonContext, extras; - reads stdin')
517
+ create
518
+ .description(kind === 'runs'
519
+ ? 'Create a run record, optionally already terminal with its results; this does not execute a job. Record parameters, comparison context and summary numbers; link logs and raw outputs with logsUri or artifacts (run data is limited by plan: 64 KB on Free)'
520
+ : 'Create an experiment with a hypothesis and optional schema version')
521
+ .requiredOption('--json <json|->', kind === 'runs'
522
+ ? 'Object requiring title; optional stub, status, config, observations, comparisonContext, extras, startedAt, finishedAt, errorSummary; - reads stdin'
523
+ : 'Object requiring name and hypothesis; optional parameters, schemaVersionId, varying, comparisonContext, extras; - reads stdin')
154
524
  .action(async (options) => {
155
525
  const payload = input(options.json);
156
526
  if (!payload || typeof payload !== 'object' || Array.isArray(payload))
157
- fail2('JSON input must be an object');
158
- const campaign = await new Scope(url()).campaign(options.campaign);
527
+ fail('JSON input must be an object');
528
+ const campaign = await apiScope().campaign(options.campaign);
159
529
  if (options.experiment) {
160
530
  if (kind !== 'runs')
161
- fail2('--experiment is only supported for runs');
531
+ fail('--experiment is only supported for runs');
162
532
  if ('experimentId' in payload)
163
- fail2('Provide the experiment via --experiment or experimentId, not both');
164
- payload.experimentId = await new Scope(url()).work('experiments', options.experiment, campaign.id);
533
+ fail('Provide the experiment via --experiment or experimentId, not both');
534
+ payload.experimentId = await apiScope().work('experiments', options.experiment, campaign.id);
165
535
  }
166
536
  return send(`${path(campaign.id)}/${kind}`, 'POST', payload);
167
537
  });
168
- group.command('update').argument('<ref>').option('--campaign <ref>', 'Defaults to the local workspace campaign').requiredOption('--json <json|->', 'Patch with expected revision')
538
+ group
539
+ .command('update')
540
+ .argument('<ref>')
541
+ .option('--campaign <ref>', 'Defaults to the local workspace campaign')
542
+ .requiredOption('--json <json|->', 'Patch with expected revision')
169
543
  .action(async (reference, options) => send(`/${kind}/${encodeURIComponent(await resolveRef(reference, options.campaign))}`, 'PATCH', input(options.json)));
170
- group.command('delete').argument('<ref>').option('--campaign <ref>', 'Defaults to the local workspace campaign').requiredOption('--revision <number>', 'Expected revision; executed runs are retained')
544
+ group
545
+ .command('delete')
546
+ .argument('<ref>')
547
+ .option('--campaign <ref>', 'Defaults to the local workspace campaign')
548
+ .requiredOption('--revision <number>', kind === 'runs'
549
+ ? 'Expected revision; only a planned run can be deleted, a run that started is kept'
550
+ : 'Expected revision')
551
+ .option('--confirm <name>', kind === 'experiments'
552
+ ? 'The experiment name, required when it holds runs: they are deleted too'
553
+ : 'Not used for runs')
171
554
  .action(async (reference, options) => {
172
555
  const revision = Number(options.revision);
173
556
  if (!Number.isSafeInteger(revision) || revision < 1)
174
557
  throw new Error('Revision must be a positive integer');
175
- return send(`/${kind}/${encodeURIComponent(await resolveRef(reference, options.campaign))}`, 'DELETE', { revision });
558
+ return send(`/${kind}/${encodeURIComponent(await resolveRef(reference, options.campaign))}`, 'DELETE', {
559
+ revision,
560
+ ...(options.confirm && kind === 'experiments' ? { confirm: options.confirm } : {}),
561
+ });
176
562
  });
563
+ if (kind === 'experiments')
564
+ for (const action of ['archive', 'restore'])
565
+ group
566
+ .command(action)
567
+ .argument('<ref>')
568
+ .option('--campaign <ref>', 'Defaults to the local workspace campaign')
569
+ .description(action === 'archive'
570
+ ? 'File an experiment away: out of lists and closed to new runs; nothing is lost'
571
+ : 'Bring an archived experiment back')
572
+ .action(async (reference, options) => send(`/experiments/${encodeURIComponent(await resolveRef(reference, options.campaign))}/${action}`, 'POST'));
573
+ }
574
+ for (const action of ['archive', 'restore']) {
575
+ campaigns
576
+ .command(action)
577
+ .argument('<ref>')
578
+ .description(action === 'archive'
579
+ ? 'File a campaign away: out of lists and closed to new work; nothing is lost'
580
+ : 'Bring an archived campaign back')
581
+ .action(async (reference) => send(`${path((await apiScope().campaign(reference)).id)}/${action}`, 'POST'));
582
+ products
583
+ .command(action)
584
+ .argument('<ref>')
585
+ .description(action === 'archive'
586
+ ? 'File a product away: out of lists and closed to new campaigns; nothing is lost'
587
+ : 'Bring an archived product back')
588
+ .action(async (reference) => send(`/products/${encodeURIComponent((await apiScope().product(reference)).id)}/${action}`, 'POST'));
177
589
  }
178
- program.addHelpText('after', '\nExamples:\n fieldwork setup campaign --product model-a --campaign memory-study\n fieldwork runs create --experiment awq --json \'{"title":"Attempt 1"}\'\n fieldwork schemas validate VERSION_ID --ready --json -\n\nData commands output JSON. Validation reports go to stdout; invalid reports exit 1.\nUse fieldwork <group> <command> --help for payload and scope guidance.');
590
+ program.addHelpText('after', '\nExamples:\n fieldwork setup campaign --product model-a --campaign memory-study\n fieldwork runs create --experiment awq --json \'{"title":"Attempt 1"}\'\n fieldwork schemas validate VERSION_ID --ready --json -\n\nData commands output JSON. Validation reports go to stdout; invalid reports exit 1.\nUse fieldwork <group> <command> --help for payload and scope guidance.\n\nFieldwork is a ledger of experiments, not a log store: record parameters, comparison\ncontext and summary numbers, and link logs and raw outputs with logsUri or artifacts.\nA refusal over a limit carries a hint saying what to record instead.');
179
591
  for (const group of program.commands) {
180
592
  for (const command of group.commands) {
181
593
  if (!command.description())
182
594
  command.description(`${command.name()} ${group.name()} records`);
183
- command.addHelpText('after', '\nServer: --url overrides FIELDWORK_URL, LAB_URL, workspace config, then http://127.0.0.1:4310.\nUse --json - for stdin where supported. IDs and stubs are accepted for records;\nschemas require immutable version IDs. Commands never launch or stop jobs.');
184
- if (['experiments', 'runs'].includes(group.name()) && ['create', 'update'].includes(command.name()))
185
- command.addHelpText('after', '\ncomparisonContext, extras, and ' + (group.name() === 'runs' ? 'config and observations' : 'parameters') + ' must be JSON objects, not strings.');
595
+ command.addHelpText('after', `\nServer: --url overrides FIELDWORK_URL, LAB_URL, workspace config, then ${DEFAULT_SERVER}.\nA local server needs --url http://127.0.0.1:4310 or FIELDWORK_URL.\nUse --json - for stdin where supported. IDs and stubs are accepted for records;\nschemas require immutable version IDs. Commands never launch or stop jobs.`);
596
+ if (['experiments', 'runs'].includes(group.name()) &&
597
+ ['create', 'update'].includes(command.name()))
598
+ command.addHelpText('after', '\ncomparisonContext, extras, and ' +
599
+ (group.name() === 'runs' ? 'config and observations' : 'parameters') +
600
+ ' must be JSON objects, not strings.');
186
601
  if (group.name() === 'experiments' && ['create', 'update'].includes(command.name()))
187
602
  command.addHelpText('after', '\nExperiment text limits: name 120 characters; hypothesis, objective, method,\nand conclusion (update only) 4000 characters each. Keep longer procedures\nin a referenced source file; include the exact extraction command in method.');
188
603
  if (command.name() === 'update')
189
604
  command.addHelpText('after', '\nUpdates require the current revision in JSON. Re-read on a conflict; do not\nblindly increment it. Config/parameter/observation objects replace the whole field.');
190
605
  }
191
606
  }
192
- campaigns.commands.find((c) => c.name() === 'create').addHelpText('after', '\nExample: fieldwork campaigns create --product model-a --json \'{"name":"Memory study","goal":"Fit in 24 GiB"}\'\nOmit --product and JSON productId for an independent campaign.');
193
- schemas.commands.find((c) => c.name() === 'publish').addHelpText('after', '\nExample definition:\n {"stub":"study","version":1,"definition":{"parameters":{"bits":{"type":"integer","values":[4,8],"required":true}}}}\nField names use snake_case. Sections: parameters, observations, comparisonContext.\nTypes: number, integer, string, boolean, enum. Published versions cannot be changed.');
194
- schemas.commands.find((c) => c.name() === 'validate').addHelpText('after', '\nReads parameters (not run config), observations and comparisonContext.\nNo records are saved. Invalid reports: stdout JSON + stderr VALIDATION_FAILED, exit 1.\nValid reports, including missing-observation warnings: exit 0. --ready checks\nrequired execution fields, but does not start a run or check experiment varying fields.');
607
+ campaigns.commands
608
+ .find((c) => c.name() === 'create')
609
+ .addHelpText('after', '\nExample: fieldwork campaigns create --product model-a --json \'{"name":"Memory study","goal":"Fit in 24 GiB"}\'\nOmit --product and JSON productId for an independent campaign.');
610
+ schemas.commands
611
+ .find((c) => c.name() === 'publish')
612
+ .addHelpText('after', '\nExample definition:\n {"stub":"study","version":1,"definition":{"parameters":{"bits":{"type":"integer","values":[4,8],"required":true}}}}\nField names use snake_case. Sections: parameters, observations, comparisonContext.\nTypes: number, integer, string, boolean, enum. Published versions cannot be changed.');
613
+ schemas.commands
614
+ .find((c) => c.name() === 'validate')
615
+ .addHelpText('after', '\nReads parameters (not run config), observations and comparisonContext.\nNo records are saved. Invalid reports: stdout JSON + stderr VALIDATION_FAILED, exit 1.\nValid reports, including missing-observation warnings: exit 0. --ready checks\nrequired execution fields, but does not start a run or check experiment varying fields.');
195
616
  try {
196
617
  await program.parseAsync();
197
618
  }
198
619
  catch (error) {
199
620
  const failure = error;
200
621
  if (failure.exitCode !== 0) {
201
- console.error(JSON.stringify({ code: failure.code ?? 'CLIENT_ERROR', message: failure.message ?? 'Request failed', hint: failure.hint ?? (failure.code?.startsWith('commander.') ? 'Run fieldwork --help or fieldwork <group> <command> --help for supported commands, arguments and required options.' : failure.code === 'REVISION_CONFLICT' ? 'Get the latest record and reconcile your patch using its current revision; do not blindly retry.' : failure.message === 'fetch failed' ? 'Check the server is running and verify --url, FIELDWORK_URL and workspace configuration; inspect state before retrying a write.' : 'Inspect any details.issues for field paths and fixes; use the command’s --help and fieldwork context to verify input and scope.'), ...(failure.status ? { status: failure.status } : {}), ...(failure.details !== undefined ? { details: failure.details } : {}) }));
622
+ console.error(JSON.stringify({
623
+ code: failure.code ?? 'CLIENT_ERROR',
624
+ message: failure.message ?? 'Request failed',
625
+ hint: failure.hint ??
626
+ (failure.code?.startsWith('commander.')
627
+ ? 'Run fieldwork --help or fieldwork <group> <command> --help for supported commands, arguments and required options.'
628
+ : failure.code === 'REVISION_CONFLICT'
629
+ ? 'Get the latest record and reconcile your patch using its current revision; do not blindly retry.'
630
+ : failure.message === 'fetch failed'
631
+ ? 'Check the server is running and verify --url, FIELDWORK_URL and workspace configuration; inspect state before retrying a write.'
632
+ : 'Inspect any details.issues for field paths and fixes; use the command’s --help and fieldwork context to verify input and scope.'),
633
+ ...(failure.status ? { status: failure.status } : {}),
634
+ ...(failure.requestId ? { requestId: failure.requestId } : {}),
635
+ ...(failure.details !== undefined ? { details: failure.details } : {}),
636
+ ...(failure.update !== undefined ? { update: failure.update } : {}),
637
+ }));
202
638
  process.exitCode = 1;
203
639
  }
204
640
  }