@gitroll/tacitt-cli 0.1.0 → 0.3.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.
Files changed (59) hide show
  1. package/dist/{agent-setup-B85GaVIz.mjs → agent-setup-CLXdKr2I.mjs} +2 -2
  2. package/dist/agent-setup-worker.mjs +5 -5
  3. package/dist/cli.mjs +165 -9
  4. package/dist/codex-title-worker.mjs +1 -1
  5. package/dist/configured-preview-worker.mjs +5 -5
  6. package/dist/configured-session-worker.mjs +25 -4
  7. package/dist/{frame-D1j9R8xX.mjs → frame-waS5FnN5.mjs} +1 -1
  8. package/dist/{heap-tfU61Ehy.mjs → heap-eO_uWpD1.mjs} +63 -9
  9. package/dist/inspection-worker.mjs +57 -0
  10. package/dist/job-rK-Ho9OD.mjs +613 -0
  11. package/dist/{locate-Bn32k2se.mjs → locate-U3-nkird.mjs} +29 -6
  12. package/dist/messages-D3m3bhva.mjs +40 -0
  13. package/dist/{model-7Nu7szNP.mjs → model-BBAnsFAx.mjs} +1 -1
  14. package/dist/operation-executor.mjs +3 -3
  15. package/dist/preview-worker.mjs +1 -1
  16. package/dist/privacy-validator.mjs +1 -1
  17. package/dist/{query-Da-KDbSV.mjs → query-DN99jiRc.mjs} +710 -630
  18. package/dist/{run-CCATsHS2.mjs → run-4G_CvdDj.mjs} +4 -4
  19. package/dist/session-process.mjs +6 -1
  20. package/dist/{src-CPPjOyHZ.mjs → src-B4R5XMEP.mjs} +621 -224
  21. package/dist/{src-BngGpFKd.mjs → src-CPbdnGrk.mjs} +130 -53
  22. package/dist/{src-DynBRDBc.mjs → src-qoLRvX-A.mjs} +34 -11
  23. package/dist/store-database-worker.mjs +1 -1
  24. package/dist/web/assets/index-BkuUI3Eu.js +67 -0
  25. package/dist/web/assets/index-Clg6Pfv_.css +1 -0
  26. package/dist/web/index.html +2 -2
  27. package/package.json +1 -1
  28. package/skills/tacitt/SKILL.md +15 -11
  29. package/skills/tacitt/references/b-method.md +1 -1
  30. package/skills/tacitt/references/classification.md +3 -3
  31. package/skills/tacitt/references/commands.md +19 -14
  32. package/skills/tacitt/references/journey.md +17 -11
  33. package/skills/tacitt/references/parallel-review.md +3 -3
  34. package/skills/tacitt/references/send.md +5 -5
  35. package/skills/tacitt/scripts/INDEX.md +6 -2
  36. package/skills/tacitt/scripts/answer.mjs +29 -8
  37. package/skills/tacitt/scripts/journey-card-consolidation.d.mts +13 -4
  38. package/skills/tacitt/scripts/journey-card-consolidation.mjs +170 -21
  39. package/skills/tacitt/scripts/journey-card-values.d.mts +17 -0
  40. package/skills/tacitt/scripts/journey-card-values.mjs +116 -0
  41. package/skills/tacitt/scripts/journey-classification-progress.mjs +2 -1
  42. package/skills/tacitt/scripts/journey-classification-validation.mjs +49 -28
  43. package/skills/tacitt/scripts/journey-classification.mjs +6 -2
  44. package/skills/tacitt/scripts/journey-consolidation-submit.mjs +42 -5
  45. package/skills/tacitt/scripts/journey-consolidation.d.mts +5 -0
  46. package/skills/tacitt/scripts/journey-consolidation.mjs +4 -1
  47. package/skills/tacitt/scripts/journey-panel-host.d.mts +9 -0
  48. package/skills/tacitt/scripts/journey-panel-host.mjs +56 -0
  49. package/skills/tacitt/scripts/journey-state.d.mts +1 -0
  50. package/skills/tacitt/scripts/journey-state.mjs +38 -6
  51. package/skills/tacitt/scripts/journey-tasks.mjs +5 -3
  52. package/skills/tacitt/scripts/journey.d.mts +4 -0
  53. package/skills/tacitt/scripts/journey.mjs +249 -41
  54. package/skills/tacitt/scripts/privacy-validator.mjs +25 -6
  55. package/skills/tacitt/scripts/review-stream-client.mjs +1 -1
  56. package/skills/tacitt/scripts/review.mjs +24 -3
  57. package/dist/job--TceKk0l.mjs +0 -194
  58. package/dist/web/assets/index-CWQ05PFl.js +0 -67
  59. package/dist/web/assets/index-J-AWt_cW.css +0 -1
@@ -1,9 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { mkdir, realpath } from 'node:fs/promises'
2
+ import { mkdir, realpath, rm } from 'node:fs/promises'
3
3
  import { join, resolve, isAbsolute } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { parseArgs } from 'node:util'
6
- import { main as review, withJourneyCli } from './review.mjs'
6
+ import { blockedWorks, main as review, withJourneyCli } from './review.mjs'
7
7
  import {
8
8
  read,
9
9
  optional,
@@ -18,11 +18,17 @@ import {
18
18
  resolutionEvidence,
19
19
  planResolutions,
20
20
  assertResolutionFresh,
21
+ uncoveredPending,
21
22
  } from './journey-state.mjs'
22
23
  import { classificationTasks } from './journey-classification-tasks.mjs'
23
24
  import { classificationEvidence, classificationFormat } from './journey-classification.mjs'
24
- import { consolidateClassification, classificationSubmission } from './journey-consolidation.mjs'
25
+ import {
26
+ consolidateClassification,
27
+ classificationSubmission,
28
+ partialClassificationSubmission,
29
+ } from './journey-consolidation.mjs'
25
30
  import { consolidateCards, cardSubmission } from './journey-card-consolidation.mjs'
31
+ import { settleCardValues } from './journey-card-values.mjs'
26
32
  import {
27
33
  classificationProgress,
28
34
  publishClassificationProgress,
@@ -30,6 +36,7 @@ import {
30
36
  } from './journey-classification-progress.mjs'
31
37
  import { answers, createTasks } from './journey-tasks.mjs'
32
38
  import { scanTiming } from './journey-scan-timing.mjs'
39
+ import { ensureDetachedHost } from './journey-panel-host.mjs'
33
40
  export { phaseOf, stageFingerprint } from './journey-state.mjs'
34
41
 
35
42
  async function publishCompleteClassification(manifest, view, call, proposalRevision) {
@@ -111,24 +118,94 @@ async function observeScanTiming(run, journeyId, view, start, call) {
111
118
  }
112
119
  }
113
120
 
121
+ const packagedCount = (view) =>
122
+ view.works.filter(
123
+ (work) =>
124
+ work.access !== 'drop' &&
125
+ work.rootSessionIds.length &&
126
+ work.status === 'ready' &&
127
+ work.artifact?.revision === work.revision,
128
+ ).length
129
+
114
130
  /** Locked by withJourneyCli in production; injected transport permits deterministic crash tests. */
115
131
  /** A running journey render (panel or CLI) with packaged/selected Work counts (BL-185). */
116
132
  function packagingProgress(view) {
117
133
  if (!view.packaging) return {}
118
134
  const selected = view.works.filter((work) => work.access !== 'drop' && work.rootSessionIds.length)
119
135
  return {
120
- packaging: {
121
- ...view.packaging,
122
- selected: selected.length,
123
- packaged: selected.filter(
124
- (work) => work.status === 'ready' && work.artifact?.revision === work.revision,
125
- ).length,
126
- },
136
+ packaging: { ...view.packaging, selected: selected.length, packaged: packagedCount(view) },
127
137
  }
128
138
  }
129
139
 
130
- async function runPhaseCore({ run, journeyId, command, stage, refresh = false, limit = 3 }, call) {
140
+ /** One await call's bound: inside a 10-minute tool timeout; the Skill repeats it to 30 minutes. */
141
+ export const maxAwaitMs = 540_000
142
+ const awaitPollMs = 1000
143
+
144
+ /**
145
+ * ADR 0227: from Ready to send the AI work is done. Packaging, upload, assessment and Passport
146
+ * completion run in the detached panel host (ADR 0217), so the agent ends its turn.
147
+ */
148
+ const handoffPhases = new Set(['ready-to-send', 'send', 'sending'])
149
+
150
+ /**
151
+ * D0: the agent waits inside its turn for the person's next panel decision. Returns once the
152
+ * helper phase changes (consent recorded, the last card answered, a Work moved out of Drop
153
+ * needing a read), a new card batch is due or a blocked Work was Dropped or retried, else after
154
+ * `waitMs` with `changed: false`. A retried Work needs `journey.prepare` again.
155
+ *
156
+ * At a handoff phase it returns at once, without waiting, once a detached panel host owns the
157
+ * panel: `handoff` tells the agent to end its turn (ADR 0227, superseding ADR 0225's wait).
158
+ */
159
+ async function awaitDecision(journeyId, waitMs, call, sleep, ensureHost) {
160
+ if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > maxAwaitMs)
161
+ throw new Error('INVALID_ARGUMENTS')
162
+ const started = Date.now()
163
+ let view = await call('journey.get', { journeyId })
164
+ const from = phaseOf(view)
165
+ const blockedFrom = blockedWorks(view).map((work) => work.workId)
166
+ for (;;) {
167
+ const phase = phaseOf(view)
168
+ const batchDue = phase === 'browser-answers' && uncoveredPending(view).length > 0
169
+ const blocked = blockedWorks(view)
170
+ const settled = blockedFrom.filter((id) => !blocked.some((work) => work.workId === id))
171
+ const retried = settled.filter((id) =>
172
+ view.works.some((work) => work.id === id && work.access !== 'drop'),
173
+ )
174
+ const changed = phase !== from || batchDue || settled.length > 0
175
+ const handoff = handoffPhases.has(phase)
176
+ if (changed || handoff || Date.now() - started >= waitMs)
177
+ return {
178
+ command: 'await',
179
+ from,
180
+ phase,
181
+ changed,
182
+ waitedMs: Date.now() - started,
183
+ revision: view.revision,
184
+ blockedWorks: blocked,
185
+ ...(retried.length ? { retriedWorks: retried, next: 'journey.prepare' } : {}),
186
+ ...(handoff ? { handoff: { panel: await ensureHost(), next: 'end-turn' } } : {}),
187
+ }
188
+ await sleep(Math.min(awaitPollMs, Math.max(0, waitMs - (Date.now() - started))))
189
+ view = await call('journey.get', { journeyId })
190
+ }
191
+ }
192
+
193
+ async function runPhaseCore(
194
+ { run, journeyId, command, stage, refresh = false, limit = 3, waitMs = 0, sleep, ensureHost },
195
+ call,
196
+ ) {
131
197
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 16) throw new Error('INVALID_ARGUMENTS')
198
+ if (command === 'await')
199
+ return awaitDecision(
200
+ journeyId,
201
+ waitMs,
202
+ call,
203
+ sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
204
+ ensureHost ??
205
+ (() => {
206
+ throw new Error('PANEL_HOST_DETACH_FAILED')
207
+ }),
208
+ )
132
209
  let view = await call('journey.get', { journeyId })
133
210
  if (command === 'scan-start')
134
211
  return {
@@ -155,6 +232,7 @@ async function runPhaseCore({ run, journeyId, command, stage, refresh = false, l
155
232
  revision: view.revision,
156
233
  readingComplete: view.readingComplete,
157
234
  pending: activeTerms(view).filter((term) => term.disposition === 'pending').length,
235
+ blockedWorks: blockedWorks(view),
158
236
  answersRevision: view.privacy.answersRevision,
159
237
  ...packagingProgress(view),
160
238
  coldElapsedMs,
@@ -177,14 +255,22 @@ async function runPhaseCore({ run, journeyId, command, stage, refresh = false, l
177
255
  const recovery = await recoverWrites(active.directory, manifest, call)
178
256
  // A failed transport leaves the exact pending request intact and never reaches refresh.
179
257
  if (recovery.recovered) view = await call('journey.get', { journeyId })
180
- if (recovery.stale && (!refresh || command !== 'tasks')) throw new Error('REVISION_CONFLICT')
181
- if (recovery.rejected && !(refresh && command === 'tasks')) {
258
+ // Only scan progress refused a classification-bound proposal: it is still current (ADR 0225).
259
+ if (
260
+ recovery.stale &&
261
+ (!refresh || command !== 'tasks') &&
262
+ !(stage === 'classification' && classificationBound(manifest, view))
263
+ )
264
+ throw new Error('REVISION_CONFLICT')
265
+ // A refused card batch is retired: the next `tasks` drafts the current Pending set.
266
+ if (recovery.rejected && !(command === 'tasks' && (refresh || manifest.batch))) {
182
267
  const error = new Error(recovery.rejected)
183
268
  error.code = recovery.rejected
184
269
  throw error
185
270
  }
186
271
  if (recovery.completed === recovery.total && !recovery.stale && command === 'ingest') {
187
- if (stage === 'classification' && manifest.consolidation)
272
+ // After the known Works, scans keep moving the revision and Works show grouping (E2).
273
+ if (stage === 'classification' && manifest.consolidation && !manifest.partial)
188
274
  await publishCompleteClassification(
189
275
  manifest,
190
276
  view,
@@ -197,25 +283,74 @@ async function runPhaseCore({ run, journeyId, command, stage, refresh = false, l
197
283
  const consolidationRefresh = refresh && command === 'consolidate' && stage === 'classification'
198
284
  if (
199
285
  consolidationRefresh &&
200
- !['classification', 'classification-confirmation'].includes(phaseOf(view))
286
+ !['classification', 'classification-confirmation'].includes(phaseOf(view)) &&
287
+ !grouping(view)
201
288
  )
202
289
  throw new Error('STAGE_NOT_READY')
203
290
  if (consolidationRefresh && (await optional(join(active?.directory ?? run, 'receipt.json'))))
204
291
  throw new Error('CLASSIFICATION_ALREADY_SUBMITTED')
292
+ // A card batch (D3) whose append was accepted, or had no open value left, is finished; one
293
+ // the CLI refused is retired. Either way the next `tasks` starts another.
294
+ const batchReceipt =
295
+ stage === 'cards' && manifest?.batch
296
+ ? (await optional(join(active.directory, 'receipt.json')))?.status
297
+ : undefined
298
+ const batchDone = ['applied', 'replayed', 'skipped'].includes(batchReceipt)
299
+ if (batchDone && command !== 'tasks') return { stage, status: batchReceipt, batchDone: true }
300
+ if (batchDone || batchReceipt === 'rejected') manifest = null
205
301
  const fingerprint = stageFingerprint(view, stage)
206
302
  if (manifest?.resolutionBatches && !refresh)
207
303
  await assertResolutionFresh(active.directory, manifest, view)
208
- else if (manifest && manifest.fingerprint !== fingerprint && (!refresh || consolidationRefresh))
304
+ // A batch names its own open values; the CLI refuses them once they are no longer open.
305
+ // Known Works this helper published (E2) changed the Works it fingerprinted.
306
+ else if (
307
+ manifest &&
308
+ !manifest.batch &&
309
+ !manifest.partial &&
310
+ manifest.fingerprint !== fingerprint &&
311
+ (!refresh || consolidationRefresh)
312
+ )
209
313
  throw new Error('STALE_TASKS')
210
314
  if (refresh && command !== 'tasks' && !consolidationRefresh) throw new Error('INVALID_ARGUMENTS')
211
315
  if (command === 'tasks') {
212
316
  const phase = phaseOf(view)
213
317
  const revisingCards =
214
318
  stage === 'cards' && refresh && ['browser-answers', 'answers-confirmation'].includes(phase)
319
+ // Batches start while reading continues and after earlier batches (D3); while reading
320
+ // continues there is no deck to replace, so `--refresh` also starts a batch.
321
+ const cardBatch =
322
+ stage === 'cards' &&
323
+ (!refresh || phase === 'scan') &&
324
+ view.scope.status === 'confirmed' &&
325
+ !['paused', 'sending', 'completed'].includes(phase) &&
326
+ uncoveredPending(view).length > 0
327
+ if (cardBatch && !manifest) {
328
+ const directory = join(run, stage, `batch-${randomUUID()}`)
329
+ await mkdir(directory, { recursive: true, mode: 0o700 })
330
+ manifest = {
331
+ format: classificationFormat,
332
+ stage,
333
+ batch: true,
334
+ fingerprint,
335
+ existingCards: view.privacy.cards.map((card) => ({
336
+ id: card.id,
337
+ question: card.question,
338
+ options: card.options.map(({ id, label }) => ({ id, label })),
339
+ answer: card.answer?.optionId ?? null,
340
+ termCount: card.termKeys.length,
341
+ })),
342
+ tasks: await createTasks(directory, stage, view, call, { batch: true }),
343
+ answersRevision: view.privacy.answersRevision,
344
+ }
345
+ await save(join(directory, 'manifest.json'), manifest)
346
+ await save(activePath, { directory })
347
+ return { stage, tasks: manifest.tasks, batch: true }
348
+ }
215
349
  if (
216
350
  phase !== stage &&
217
351
  !revisingCards &&
218
- !(stage === 'classification' && phase === 'classification-confirmation')
352
+ !(cardBatch && manifest) &&
353
+ !(stage === 'classification' && (phase === 'classification-confirmation' || grouping(view)))
219
354
  )
220
355
  throw new Error('STAGE_NOT_READY')
221
356
  if (manifest && !refresh && stage === 'classification') {
@@ -240,6 +375,10 @@ async function runPhaseCore({ run, journeyId, command, stage, refresh = false, l
240
375
  fingerprint,
241
376
  tasks: stage === 'classification' ? [] : await createTasks(directory, stage, view, call),
242
377
  answersRevision: view.privacy.answersRevision,
378
+ // Refreshed after the known Works (E2): the complete proposal stays bound to classification.
379
+ ...(stage === 'classification' && grouping(view)
380
+ ? { partial: { status: 'applied', revision: view.classificationRevision } }
381
+ : {}),
243
382
  }
244
383
  await save(join(directory, 'manifest.json'), manifest)
245
384
  await save(activePath, { directory })
@@ -318,6 +457,15 @@ async function runPhaseCore({ run, journeyId, command, stage, refresh = false, l
318
457
  total: manifest.resolutionBatches.length,
319
458
  }
320
459
  }
460
+ if (stage === 'classification' && command === 'ingest' && !manifest.partial) {
461
+ const readiness = manifest.consolidation
462
+ ? await consolidateClassification(directory, manifest)
463
+ : { readyForIngest: false }
464
+ if (!readiness.readyForIngest) {
465
+ const partial = await partialClassificationSubmission(manifest)
466
+ if (partial) return ingestKnownWorks(directory, manifest, view, call, partial)
467
+ }
468
+ }
321
469
  if (stage === 'classification' && !manifest.consolidation)
322
470
  throw new Error('CONSOLIDATION_REQUIRED')
323
471
  if (stage === 'classification') {
@@ -330,42 +478,99 @@ async function runPhaseCore({ run, journeyId, command, stage, refresh = false, l
330
478
  }
331
479
  const key = stage === 'classification' ? 'items' : stage === 'cards' ? 'cards' : 'resolutions'
332
480
  const tasks = stage === 'classification' ? [manifest.consolidation] : manifest.tasks
333
- const request = {
334
- journeyId,
335
- expectedRevision: view.revision,
336
- idempotencyKey: randomUUID(),
337
- [key]:
338
- stage === 'classification'
339
- ? await classificationSubmission(manifest, view)
340
- : stage === 'cards'
341
- ? await cardSubmission(manifest)
342
- : await answers(tasks, key),
343
- ...(stage === 'resolution' ? { answersRevision: manifest.answersRevision } : {}),
481
+ // Later scan packs may hide covered values between `tasks` and `ingest`: submit only what is
482
+ // still open, and give every value one card (F4).
483
+ const cards = stage === 'cards' ? settleCardValues(await cardSubmission(manifest), view) : null
484
+ if (cards && manifest.batch && !cards.cards.length && !cards.extend?.length) {
485
+ await save(join(directory, 'receipt.json'), { status: 'skipped' })
486
+ return { stage, status: 'skipped', batchDone: true }
344
487
  }
345
- await save(join(directory, 'request.json'), request)
346
488
  let result
347
- try {
348
- result = await call(procedureFor(stage), request)
349
- await save(join(directory, 'receipt.json'), {
350
- status: result.status,
351
- revision: result.state?.revision,
352
- })
353
- } catch (error) {
354
- if (definiteWriteRejection(error))
489
+ for (let attempt = 0; ; attempt++) {
490
+ const request = {
491
+ journeyId,
492
+ expectedRevision: view.revision,
493
+ idempotencyKey: randomUUID(),
494
+ // After the known Works, scans keep moving the revision: bind to the classification (E2).
495
+ ...(stage === 'classification' && manifest.partial
496
+ ? { expectedClassificationRevision: manifest.partial.revision }
497
+ : {}),
498
+ ...(stage === 'classification'
499
+ ? { [key]: await classificationSubmission(manifest, view) }
500
+ : stage === 'cards'
501
+ ? cards
502
+ : { [key]: await answers(tasks, key) }),
503
+ ...(stage === 'resolution' ? { answersRevision: manifest.answersRevision } : {}),
504
+ }
505
+ await save(join(directory, 'request.json'), request)
506
+ try {
507
+ result = await call(procedureFor(stage), request)
355
508
  await save(join(directory, 'receipt.json'), {
356
- status: 'rejected',
357
- code: error.code,
509
+ status: result.status,
510
+ revision: result.state?.revision,
358
511
  })
359
- throw error
512
+ } catch (error) {
513
+ if (definiteWriteRejection(error))
514
+ await save(join(directory, 'receipt.json'), {
515
+ status: 'rejected',
516
+ code: error.code,
517
+ })
518
+ throw error
519
+ }
520
+ if (result.status !== 'stale' || stage !== 'classification' || attempt >= boundRetries) break
521
+ // A scan write between the CLI's read and its commit refuses a still-current proposal
522
+ // (ADR 0225): submit it again against the current revision.
523
+ view = await call('journey.get', { journeyId })
524
+ if (!classificationBound(manifest, view)) break
360
525
  }
361
526
  if (result.status === 'stale') throw new Error('REVISION_CONFLICT')
362
- if (stage === 'classification') {
527
+ if (stage === 'classification' && !manifest.partial) {
363
528
  const latest = await call('journey.get', { journeyId })
364
529
  await publishCompleteClassification(manifest, latest, call, result.state?.revision)
365
530
  }
366
531
  return { stage, status: result.status, revision: result.state?.revision }
367
532
  }
368
533
 
534
+ /** Classification published known Works and is still grouping the remaining roots (E2). */
535
+ const grouping = (view) => view.works.some((work) => work.placement === 'classifying')
536
+
537
+ /** The complete proposal binds to the classification it replaces, not to scan progress (E2). */
538
+ const classificationBound = (manifest, view) =>
539
+ Boolean(manifest.partial) &&
540
+ view.classificationRevision === manifest.partial.revision &&
541
+ grouping(view)
542
+ /** Submissions of an unchanged classification-bound proposal after a stale answer. */
543
+ const boundRetries = 3
544
+
545
+ /**
546
+ * E2: publishes known-project Works while unresolved roots are still being grouped, so their
547
+ * scan can start. Its request and receipt are kept apart from the final proposal's.
548
+ */
549
+ async function ingestKnownWorks(directory, manifest, view, call, partial) {
550
+ const requestPath = join(directory, 'partial.request.json')
551
+ const request = (await optional(requestPath)) ?? {
552
+ journeyId: view.journeyId,
553
+ expectedRevision: view.revision,
554
+ idempotencyKey: randomUUID(),
555
+ ...partial,
556
+ }
557
+ await save(requestPath, request)
558
+ const result = await call(procedureFor('classification'), request)
559
+ if (result.status === 'stale') {
560
+ await rm(requestPath, { force: true })
561
+ throw new Error('REVISION_CONFLICT')
562
+ }
563
+ manifest.partial = { status: result.status, revision: result.state?.revision }
564
+ await save(join(directory, 'manifest.json'), manifest)
565
+ return {
566
+ stage: 'classification',
567
+ status: result.status,
568
+ partial: true,
569
+ pendingRoots: partial.pendingRootSessionIds.length,
570
+ revision: result.state?.revision,
571
+ }
572
+ }
573
+
369
574
  const terminalClassificationErrors = new Set([
370
575
  'INVALID_SELECTION',
371
576
  'CLASSIFICATION_PROJECT_NAME_CONFLICT',
@@ -410,6 +615,7 @@ export async function main(argv) {
410
615
  stage: { type: 'string' },
411
616
  refresh: { type: 'boolean', default: false },
412
617
  limit: { type: 'string' },
618
+ 'wait-ms': { type: 'string' },
413
619
  model: { type: 'string' },
414
620
  effort: { type: 'string' },
415
621
  },
@@ -434,6 +640,8 @@ export async function main(argv) {
434
640
  stage: values.stage,
435
641
  refresh: values.refresh,
436
642
  limit: values.limit === undefined ? 3 : Number(values.limit),
643
+ waitMs: values['wait-ms'] === undefined ? 300_000 : Number(values['wait-ms']),
644
+ ensureHost: () => ensureDetachedHost(config.cli, config.journeyId),
437
645
  },
438
646
  call,
439
647
  ),
@@ -9468,13 +9468,18 @@ const decisionCardInputSchema = object({
9468
9468
  id: _enum(["hide", "keep"]),
9469
9469
  label: string().trim().min(1).max(240),
9470
9470
  rule: string().trim().min(1).max(2e3)
9471
- }).strict()).length(2).refine((options) => new Set(options.map((option) => option.id)).size === 2, { message: "Decision cards require one hide option and one keep option." })
9471
+ }).strict()).length(2).refine((options) => new Set(options.map((option) => option.id)).size === 2, { message: "Decision cards require one hide option and one keep option." }),
9472
+ suggestion: object({
9473
+ optionId: _enum(["hide", "keep"]),
9474
+ source: string().trim().min(1).max(300)
9475
+ }).strict().optional()
9472
9476
  }).strict();
9473
9477
  const decisionCardSchema = decisionCardInputSchema.extend({ answer: object({
9474
9478
  optionId: string(),
9475
9479
  rule: string().max(4001),
9476
9480
  additionalRule: string().max(2e3),
9477
- remember: boolean()
9481
+ remember: boolean(),
9482
+ excludedTermKeys: array(string().min(1)).max(2e4).optional()
9478
9483
  }).strict().nullable() }).strict();
9479
9484
  const journeyPrivacySchema = object({
9480
9485
  terms: array(privacyTermSchema),
@@ -9490,7 +9495,14 @@ const write = object({
9490
9495
  expectedRevision: count$2,
9491
9496
  idempotencyKey: uuid()
9492
9497
  });
9493
- write.extend({ cards: array(decisionCardInputSchema).max(20) }).strict();
9498
+ write.extend({
9499
+ cards: array(decisionCardInputSchema).max(20),
9500
+ mode: _enum(["replace", "append"]).optional(),
9501
+ extend: array(object({
9502
+ cardId: uuid(),
9503
+ termKeys: array(string().min(1)).min(1).max(2e4)
9504
+ }).strict()).max(20).optional()
9505
+ }).strict();
9494
9506
  const privacyResolveSchema = write.extend({
9495
9507
  answersRevision: count$2,
9496
9508
  resolutions: array(privacyTermSchema.omit({ sources: true }).extend({ disposition: _enum(["hidden", "keep"]) }).strict()).min(1).max(2e4)
@@ -9500,7 +9512,8 @@ object({
9500
9512
  cardId: uuid(),
9501
9513
  optionId: string().min(1),
9502
9514
  rule: string().max(2e3).optional(),
9503
- remember: boolean()
9515
+ remember: boolean(),
9516
+ excludedTermKeys: array(string().min(1)).max(2e4).optional()
9504
9517
  }).strict();
9505
9518
  object({
9506
9519
  kind: literal("cards-confirm"),
@@ -9815,7 +9828,11 @@ const journeyWorkSchema = object({
9815
9828
  "explicit",
9816
9829
  "delegate"
9817
9830
  ]),
9818
- placement: _enum(["placed", "unsorted"]),
9831
+ placement: _enum([
9832
+ "placed",
9833
+ "unsorted",
9834
+ "classifying"
9835
+ ]),
9819
9836
  suggestion: journeyWorkSuggestionSchema.nullable(),
9820
9837
  classificationRecommendation: object({
9821
9838
  access: journeyAccessSchema,
@@ -9862,7 +9879,9 @@ const journeyWorkSchema = object({
9862
9879
  staticReview: object({
9863
9880
  snapshotId: journeyDigestSchema,
9864
9881
  inputRevision: count$1.min(1),
9865
- digest: journeyDigestSchema
9882
+ digest: journeyDigestSchema,
9883
+ /** Images the Work's sessions leave out (D7); absent when a session receipt predates it. */
9884
+ imagesOmitted: count$1.optional()
9866
9885
  }).strict().optional(),
9867
9886
  totalBatches: count$1,
9868
9887
  completedBatches: count$1,
@@ -160,7 +160,7 @@ export function startReviewStream(
160
160
  return current
161
161
  }
162
162
 
163
- function windowsArgument(value) {
163
+ export function windowsArgument(value) {
164
164
  return /^[\w./:@=+-]+$/u.test(value) ? value : `"${value.replaceAll('"', '""')}"`
165
165
  }
166
166
 
@@ -1,4 +1,4 @@
1
- import { phaseOf } from './journey-state.mjs'
1
+ import { cardBatchOpen, phaseOf, uncoveredPending } from './journey-state.mjs'
2
2
  import { startReviewStream } from './review-stream-client.mjs'
3
3
  import { createHash, randomUUID } from 'node:crypto'
4
4
  import {
@@ -398,7 +398,7 @@ export function renderGuidance(guidance) {
398
398
  "## The person's instructions",
399
399
  'A hide instruction is a requirement: report every value it covers under the closest type. A keep instruction is context, not proof that other text is public.',
400
400
  instructions.join('\n') || '(none)',
401
- 'Remembered rules (apply only when the context fits):',
401
+ 'Remembered rules and answered questions (apply only when the context fits). A value that fits an answered question takes its answer: disposition hidden for Hide, keep for Keep, not pending.',
402
402
  ...(guidance?.rules ?? []),
403
403
  ].join('\n\n'),
404
404
  ) + '\n'
@@ -1317,7 +1317,7 @@ const termRequest = (ctx, units, terms, completeUnits = true) => ({
1317
1317
 
1318
1318
  /** Per-Work scan progress; final preview and render also require the Work's Track B review. */
1319
1319
  export function workProgress(view) {
1320
- const finalPreview = ['final-preview-and-confirmation', 'package', 'send'].includes(phaseOf(view))
1320
+ const finalPreview = ['ready-to-send', 'send'].includes(phaseOf(view))
1321
1321
  return view.works.map((work) => {
1322
1322
  const selected = work.access !== 'drop' && work.rootSessionIds.length > 0
1323
1323
  const proof = work.emptyReview
@@ -1384,9 +1384,12 @@ export function questionsReady(view) {
1384
1384
  /** Reading and question state after this command's recordings. */
1385
1385
  async function progressOf(ctx) {
1386
1386
  const view = await call(ctx, 'journey.get', { journeyId: ctx.journeyId })
1387
+ const uncovered = uncoveredPending(view).length
1387
1388
  return {
1388
1389
  readingComplete: view.readingComplete,
1389
1390
  questionsReady: questionsReady(view),
1391
+ // E3: a card is raised as soon as a value needs one, unless a batch is still being drafted.
1392
+ cardBatch: { uncovered, ready: uncovered > 0 && !(await cardBatchOpen(ctx.run)) },
1390
1393
  works: workProgress(view),
1391
1394
  blockedWorks: blockedWorks(view),
1392
1395
  }
@@ -1512,6 +1515,24 @@ export async function withJourneyCli(run, operation) {
1512
1515
  }
1513
1516
  }
1514
1517
 
1518
+ /**
1519
+ * A CLI binding without the run lock, for a child that only publishes progress (BL-202) while
1520
+ * the agent's helper holds the lock.
1521
+ */
1522
+ export async function withUnlockedJourneyCli(run, operation) {
1523
+ const config = await readJson(join(run, 'config.json'), null)
1524
+ if (!config) fail('RUN_NOT_INITIALIZED')
1525
+ const ctx = { run, config, journeyId: config.journeyId }
1526
+ try {
1527
+ return await operation(
1528
+ (procedure, input, options) => call(ctx, procedure, input, options),
1529
+ config,
1530
+ )
1531
+ } finally {
1532
+ await Promise.all((ctx.streams ?? []).map((item) => item.close()))
1533
+ }
1534
+ }
1535
+
1515
1536
  export async function main(argv) {
1516
1537
  const { values, positionals } = parseArgs({
1517
1538
  args: argv,