@gitroll/tacitt-cli 0.2.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 (42) hide show
  1. package/dist/{agent-setup-B8DAiQ6Z.mjs → agent-setup-CLXdKr2I.mjs} +1 -1
  2. package/dist/agent-setup-worker.mjs +4 -4
  3. package/dist/cli.mjs +65 -8
  4. package/dist/codex-title-worker.mjs +1 -1
  5. package/dist/configured-preview-worker.mjs +4 -4
  6. package/dist/configured-session-worker.mjs +25 -4
  7. package/dist/{heap-BHcvxWCw.mjs → heap-eO_uWpD1.mjs} +45 -3
  8. package/dist/inspection-worker.mjs +3 -3
  9. package/dist/job-rK-Ho9OD.mjs +613 -0
  10. package/dist/{model-BJdTLNjT.mjs → model-BBAnsFAx.mjs} +1 -1
  11. package/dist/operation-executor.mjs +2 -2
  12. package/dist/preview-worker.mjs +1 -1
  13. package/dist/{query-BA0XCwnR.mjs → query-DN99jiRc.mjs} +273 -551
  14. package/dist/{run-BeLHsZWb.mjs → run-4G_CvdDj.mjs} +2 -2
  15. package/dist/session-process.mjs +6 -1
  16. package/dist/{src-CC95LYYp.mjs → src-B4R5XMEP.mjs} +370 -176
  17. package/dist/{src-k0Ya1Bri.mjs → src-CPbdnGrk.mjs} +118 -47
  18. package/dist/{src-B5SArpVY.mjs → src-qoLRvX-A.mjs} +17 -5
  19. package/dist/store-database-worker.mjs +1 -1
  20. package/dist/web/assets/index-BkuUI3Eu.js +67 -0
  21. package/dist/web/assets/index-Clg6Pfv_.css +1 -0
  22. package/dist/web/index.html +2 -2
  23. package/package.json +1 -1
  24. package/skills/tacitt/SKILL.md +8 -6
  25. package/skills/tacitt/references/commands.md +14 -9
  26. package/skills/tacitt/references/journey.md +10 -6
  27. package/skills/tacitt/references/send.md +1 -1
  28. package/skills/tacitt/scripts/INDEX.md +6 -2
  29. package/skills/tacitt/scripts/journey-card-consolidation.mjs +90 -4
  30. package/skills/tacitt/scripts/journey-card-values.d.mts +17 -0
  31. package/skills/tacitt/scripts/journey-card-values.mjs +116 -0
  32. package/skills/tacitt/scripts/journey-classification.mjs +6 -2
  33. package/skills/tacitt/scripts/journey-panel-host.d.mts +9 -0
  34. package/skills/tacitt/scripts/journey-panel-host.mjs +56 -0
  35. package/skills/tacitt/scripts/journey-state.mjs +10 -4
  36. package/skills/tacitt/scripts/journey-tasks.mjs +1 -1
  37. package/skills/tacitt/scripts/journey.d.mts +2 -0
  38. package/skills/tacitt/scripts/journey.mjs +120 -53
  39. package/skills/tacitt/scripts/review-stream-client.mjs +1 -1
  40. package/dist/job-J3NFw3MH.mjs +0 -194
  41. package/dist/web/assets/index-Bjos73wK.js +0 -67
  42. package/dist/web/assets/index-Bl4C2b2_.css +0 -1
@@ -0,0 +1,116 @@
1
+ import { uncoveredPending } from './journey-state.mjs'
2
+
3
+ const keyOf = (term) => term.key ?? JSON.stringify([term.type, term.value?.normalize('NFC')])
4
+ const normalized = (value) => value.normalize('NFC').trim().toLowerCase()
5
+
6
+ /** Examples that differ only in case, spacing, hyphens or underscores show one value. */
7
+ export const exampleKey = (value) =>
8
+ value
9
+ .normalize('NFKC')
10
+ .toLowerCase()
11
+ .replace(/[\s_-]+/gu, '')
12
+ const exampleLength = 80
13
+ const clip = (value) => {
14
+ const points = [...value.trim()]
15
+ return points.length > exampleLength
16
+ ? `${points
17
+ .slice(0, exampleLength - 1)
18
+ .join('')
19
+ .trimEnd()}…`
20
+ : points.join('')
21
+ }
22
+ /** Short, distinct examples in their given order; `drop` removes values that belong elsewhere. */
23
+ export function cardExamples(examples, drop = () => false) {
24
+ const seen = new Set(),
25
+ kept = []
26
+ for (const example of examples) {
27
+ if (typeof example !== 'string') continue
28
+ const key = exampleKey(example)
29
+ if (!key || seen.has(key) || drop(example)) continue
30
+ seen.add(key)
31
+ kept.push(clip(example))
32
+ }
33
+ return kept
34
+ }
35
+
36
+ /**
37
+ * Settles a card publication against the current journey before it is sent.
38
+ *
39
+ * - An appended batch submits only values that are still open: a later scan pack may have hidden
40
+ * a covered value between `tasks` and `ingest`, and the CLI refuses the whole batch for it.
41
+ * - Every normalized value goes to one card (F4). A value the scan found as the person's own
42
+ * identity (`own-identity`, or an own-identity value rule) goes to the card that asks about it,
43
+ * so a Hide answer about other people cannot hide the person's own handle. Any other value
44
+ * joins the first card that already covers the same value.
45
+ *
46
+ * Cards left without values are dropped; their extensions too. A new card's examples are
47
+ * deduplicated and shortened, and one that is not the own-identity card shows no own-identity
48
+ * value; when nothing is left it shows its first covered values.
49
+ */
50
+ export function settleCardValues(submission, view) {
51
+ const terms = new Map(view.privacy.terms.map((term) => [keyOf(term), term]))
52
+ const valueOf = (key) => {
53
+ const value = terms.get(key)?.value
54
+ return typeof value === 'string' ? normalized(value) : `key:${key}`
55
+ }
56
+ const own = new Set(
57
+ [...view.privacy.terms, ...(view.valueOverrides ?? [])]
58
+ .filter((term) => term.type === 'own-identity' && typeof term.value === 'string')
59
+ .map((term) => normalized(term.value)),
60
+ )
61
+ const append = submission.mode === 'append'
62
+ const open = append ? new Set(uncoveredPending(view).map(keyOf)) : null
63
+ const proposed = new Map([
64
+ ...(submission.extend ?? []).map((item) => [item.cardId, item.termKeys]),
65
+ ...submission.cards.map((card) => [card.id, card.termKeys]),
66
+ ])
67
+ const slots = [
68
+ ...(append ? view.privacy.cards : []).map((card) => ({
69
+ id: card.id,
70
+ published: card.termKeys,
71
+ keys: [],
72
+ })),
73
+ ...submission.cards.map((card) => ({ id: card.id, published: [], keys: [] })),
74
+ ]
75
+ const ownCount = (slot) =>
76
+ [...slot.published, ...(proposed.get(slot.id) ?? [])].filter(
77
+ (key) => terms.get(key)?.type === 'own-identity',
78
+ ).length
79
+ const ownSlot = slots.reduce(
80
+ (best, slot) => (ownCount(slot) > (best ? ownCount(best) : 0) ? slot : best),
81
+ null,
82
+ )
83
+ const home = new Map()
84
+ for (const slot of slots)
85
+ for (const key of slot.published) if (!home.has(valueOf(key))) home.set(valueOf(key), slot)
86
+ for (const slot of slots)
87
+ for (const key of proposed.get(slot.id) ?? []) {
88
+ if (open && !open.has(key)) continue
89
+ const value = valueOf(key)
90
+ const target = (own.has(value) && ownSlot) || home.get(value) || slot
91
+ if (!home.has(value)) home.set(value, target)
92
+ target.keys.push(key)
93
+ }
94
+ const settled = new Map(slots.map((slot) => [slot.id, slot.keys]))
95
+ const created = new Set(submission.cards.map((card) => card.id))
96
+ const extend = slots
97
+ .filter((slot) => !created.has(slot.id) && slot.keys.length)
98
+ .map((slot) => ({ cardId: slot.id, termKeys: slot.keys }))
99
+ const { extend: _proposedExtend, ...rest } = submission
100
+ const examplesOf = (card, keys) => {
101
+ const drop = card.id === ownSlot?.id ? () => false : (example) => own.has(normalized(example))
102
+ const examples = cardExamples(card.examples ?? [], drop)
103
+ const covered = cardExamples(keys.map((key) => terms.get(key)?.value)).slice(0, 3)
104
+ return examples.length ? examples : covered.length ? covered : card.examples
105
+ }
106
+ return {
107
+ ...rest,
108
+ cards: submission.cards
109
+ .map((card) => {
110
+ const termKeys = settled.get(card.id) ?? []
111
+ return { ...card, examples: examplesOf(card, termKeys), termKeys }
112
+ })
113
+ .filter((card) => card.termKeys.length),
114
+ ...(extend.length ? { extend } : {}),
115
+ }
116
+ }
@@ -74,9 +74,13 @@ export async function classificationEvidence(directory, manifest, view, call, li
74
74
  const root = state.roots.find((entry) => entry.key === key)
75
75
  const source = state.unknown.find((entry) => entry.rootKey === key)
76
76
  if (!root || !source) throw new Error('INVALID_EVIDENCE_ROOTS')
77
+ // After the known Works (E2), unresolved roots sit in the "Still being grouped" Work.
78
+ const workId =
79
+ view.works.find((work) => work.rootSessionIds?.includes(root.rootSessionId))?.id ??
80
+ root.workId
77
81
  const page = await call('journey.classificationSessions', {
78
82
  journeyId: view.journeyId,
79
- workId: root.workId,
83
+ workId,
80
84
  includeProjectEvidence: true,
81
85
  projectEvidenceRootSessionIds: [root.rootSessionId],
82
86
  rootsOnly: true,
@@ -97,7 +101,7 @@ export async function classificationEvidence(directory, manifest, view, call, li
97
101
  for (let pageNumber = 0; pageNumber < 2; pageNumber++) {
98
102
  const content = await call('journey.classificationRead', {
99
103
  journeyId: view.journeyId,
100
- workId: root.workId,
104
+ workId,
101
105
  rootSessionId: root.rootSessionId,
102
106
  ...(cursor ? { cursor } : {}),
103
107
  })
@@ -0,0 +1,9 @@
1
+ import { type ChildProcess, type SpawnOptions } from 'node:child_process'
2
+
3
+ export function ensureDetachedHost(
4
+ cli: string[],
5
+ journeyId: string,
6
+ options?: {
7
+ spawnChild?: (command: string, args: string[], options: SpawnOptions) => ChildProcess
8
+ },
9
+ ): Promise<{ url: string; pid: number; started: boolean }>
@@ -0,0 +1,56 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { windowsArgument } from './review-stream-client.mjs'
3
+
4
+ /** `journey.serve --detach` waits up to 60 s for the host; leave room for the CLI's startup. */
5
+ const detachTimeoutMs = 90_000
6
+
7
+ /**
8
+ * ADR 0227: before the agent ends its turn at Ready to send, a detached panel host must own the
9
+ * panel, because some agent hosts (Codex) stop the processes of a turn when it ends. Runs the
10
+ * paired CLI's `journey.serve --detach`, which reuses a detached host already serving the journey
11
+ * and otherwise starts one. Resolves `{url, pid, started}`; `started` means the panel moved to
12
+ * a new URL.
13
+ */
14
+ export function ensureDetachedHost(cli, journeyId, { spawnChild = spawn } = {}) {
15
+ const [command, ...prefix] = cli
16
+ const shell = process.platform === 'win32' && !/\.(exe|com)$/iu.test(command)
17
+ const args = [...prefix, 'journey.serve', '--detach', '--input', '-']
18
+ return new Promise((resolve, reject) => {
19
+ const fail = () => reject(new Error('PANEL_HOST_DETACH_FAILED'))
20
+ const child = spawnChild(command, shell ? args.map(windowsArgument) : args, {
21
+ stdio: ['pipe', 'pipe', 'ignore'],
22
+ shell,
23
+ })
24
+ let output = ''
25
+ const timer = setTimeout(() => child.kill(), detachTimeoutMs)
26
+ child.stdout.setEncoding('utf8')
27
+ child.stdout.on('data', (chunk) => (output += chunk))
28
+ child.once('error', () => {
29
+ clearTimeout(timer)
30
+ fail()
31
+ })
32
+ child.once('close', (code) => {
33
+ clearTimeout(timer)
34
+ const ready = output
35
+ .split('\n')
36
+ .flatMap((line) => {
37
+ try {
38
+ return [JSON.parse(line)]
39
+ } catch {
40
+ return []
41
+ }
42
+ })
43
+ .find((line) => line?.ok === true && line.event === 'ready')?.result
44
+ if (
45
+ code !== 0 ||
46
+ ready?.journeyId !== journeyId ||
47
+ typeof ready.url !== 'string' ||
48
+ !Number.isSafeInteger(ready.pid)
49
+ )
50
+ return fail()
51
+ resolve({ url: ready.url, pid: ready.pid, started: ready.reused !== true })
52
+ })
53
+ child.stdin.on('error', () => {})
54
+ child.stdin.end(JSON.stringify({ journeyId }))
55
+ })
56
+ }
@@ -65,7 +65,8 @@ export async function cardBatchOpen(run) {
65
65
  const manifest = await optional(join(active.directory, 'manifest.json'))
66
66
  if (!manifest?.batch) return false
67
67
  const receipt = await optional(join(active.directory, 'receipt.json'))
68
- return !['applied', 'replayed'].includes(receipt?.status)
68
+ // A refused batch is retired and an emptied one finished: neither holds the next batch back.
69
+ return !['applied', 'replayed', 'skipped', 'rejected'].includes(receipt?.status)
69
70
  }
70
71
  /** Pending values that no published card covers yet: the input of the next card batch. */
71
72
  export function uncoveredPending(view) {
@@ -76,16 +77,21 @@ export function uncoveredPending(view) {
76
77
  !covered.has(term.key ?? JSON.stringify([term.type, term.value?.normalize('NFC')])),
77
78
  )
78
79
  }
80
+ /**
81
+ * Classification tasks depend on grouping, not on scan progress: the known Works' scan keeps
82
+ * moving their input revision and the person may change access, so neither retires them.
83
+ */
79
84
  export function stageFingerprint(view, stage) {
85
+ const classification = stage === 'classification'
80
86
  return hash({
81
87
  snapshotId: view.snapshotId,
82
88
  guidance: view.guidanceDigest,
83
89
  works: view.works.map((work) => [
84
90
  work.id,
85
- work.inputRevision,
91
+ classification ? null : work.inputRevision,
86
92
  work.rootSessionIds,
87
- work.access,
88
- stage === 'classification'
93
+ classification ? null : work.access,
94
+ classification
89
95
  ? [
90
96
  work.title,
91
97
  work.projectName,
@@ -122,7 +122,7 @@ export async function createTasks(directory, stage, view, call, { batch = false
122
122
  for (const group of groups) {
123
123
  const instruction =
124
124
  stage === 'cards'
125
- ? 'INTERNAL decision-card drafting for Main (not browser publication): return {cards:[{id,question,examples,termRefs,options:[{id,label,rule}]}]}. Use UUID card IDs. termRefs lists the supplied term refs (for example "T12"); cover every supplied ref exactly once. Ask a concise contextual retention question with exactly two options: id hide (hide the covered values) and id keep (retain the covered values). Keep their rules meaningful for final resolution. Group by broad privacy-information category and shared retention policy. Specific projects, tools, filenames and capabilities belong in examples, not separate question topics. Main alone reads the complete candidate catalog and reconciles drafts globally into 5–10 final questions for the entire journey, never more than 20. Preserve coherent decision boundaries and exact disjoint keys; batch boundaries must never become final questions. Identify affected Work names only as context. Never answer for the person.'
125
+ ? "INTERNAL decision-card drafting for Main (not browser publication): return {cards:[{id,question,examples,termRefs,options:[{id,label,rule}]}]}. Use UUID card IDs. termRefs lists the supplied term refs (for example \"T12\"); cover every supplied ref exactly once. Write the question, option labels, rules and any context in English whatever language the conversation uses; keep example values exactly as they appear in the source. Ask a concise contextual retention question with exactly two options: id hide (hide the covered values) and id keep (retain the covered values). Keep their rules meaningful for final resolution. Group by broad privacy-information category and shared retention policy. Specific projects, tools, filenames and capabilities belong in examples, not separate question topics. Never put the person's own identity and other people on one card: an own-identity card covers only values tied to the person's own signals (own-identity terms, the session author, local account, own handle, email or domain); when unsure whose a value is, put it on the other-people card. Common review or chat jargon (for example LGTM, PTAL, nit, WIP) and generic words are not privacy values: never use them as examples or as a question's subject, and cover their refs on the card that fits the context they came from. Examples show short values, never sentences: list each value once, ignoring case, spacing, hyphens and underscores, and cut a long value with a trailing ellipsis. Main alone reads the complete candidate catalog and reconciles drafts globally into 5–10 final questions for the entire journey, never more than 20. Preserve coherent decision boundaries and exact disjoint keys; batch boundaries must never become final questions. Identify affected Work names only as context. Never answer for the person."
126
126
  : 'Final privacy resolution: return {resolutions:[{type,value,disposition,reason}]}. Resolve every supplied term strictly by the confirmed card answer: Hide gives "hidden" and Keep gives "keep" for every covered term; never override the answer of the person with your own judgment; when the same value is also under another card answered Hide, the CLI makes it Hidden, so still resolve by the answer of this card (credentials and contact details are masked separately and never reach these tasks). Use guidance only for a term the answer does not settle. disposition MUST be exactly the string "hidden" or "keep" (the card option id "hide" means "hidden"), never pending. If rules cannot settle a value, report explicit failure instead of inventing an answer.'
127
127
  const base = stage === 'cards' ? {} : { card: { ...group.card, termKeys: undefined }, guidance }
128
128
  const dataFor = (terms) => {
@@ -35,6 +35,8 @@ export function runPhase(
35
35
  limit?: number
36
36
  waitMs?: number
37
37
  sleep?: (ms: number) => Promise<void>
38
+ /** Ensures a detached panel host at a handoff phase (ADR 0227). */
39
+ ensureHost?: () => Promise<{ url: string; pid: number; started: boolean }>
38
40
  },
39
41
  call: (procedure: string, input: unknown) => Promise<unknown>,
40
42
  ): Promise<Record<string, unknown>>
@@ -3,7 +3,7 @@ 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,
@@ -28,6 +28,7 @@ import {
28
28
  partialClassificationSubmission,
29
29
  } from './journey-consolidation.mjs'
30
30
  import { consolidateCards, cardSubmission } from './journey-card-consolidation.mjs'
31
+ import { settleCardValues } from './journey-card-values.mjs'
31
32
  import {
32
33
  classificationProgress,
33
34
  publishClassificationProgress,
@@ -35,6 +36,7 @@ import {
35
36
  } from './journey-classification-progress.mjs'
36
37
  import { answers, createTasks } from './journey-tasks.mjs'
37
38
  import { scanTiming } from './journey-scan-timing.mjs'
39
+ import { ensureDetachedHost } from './journey-panel-host.mjs'
38
40
  export { phaseOf, stageFingerprint } from './journey-state.mjs'
39
41
 
40
42
  async function publishCompleteClassification(manifest, view, call, proposalRevision) {
@@ -116,19 +118,22 @@ async function observeScanTiming(run, journeyId, view, start, call) {
116
118
  }
117
119
  }
118
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
+
119
130
  /** Locked by withJourneyCli in production; injected transport permits deterministic crash tests. */
120
131
  /** A running journey render (panel or CLI) with packaged/selected Work counts (BL-185). */
121
132
  function packagingProgress(view) {
122
133
  if (!view.packaging) return {}
123
134
  const selected = view.works.filter((work) => work.access !== 'drop' && work.rootSessionIds.length)
124
135
  return {
125
- packaging: {
126
- ...view.packaging,
127
- selected: selected.length,
128
- packaged: selected.filter(
129
- (work) => work.status === 'ready' && work.artifact?.revision === work.revision,
130
- ).length,
131
- },
136
+ packaging: { ...view.packaging, selected: selected.length, packaged: packagedCount(view) },
132
137
  }
133
138
  }
134
139
 
@@ -136,28 +141,49 @@ function packagingProgress(view) {
136
141
  export const maxAwaitMs = 540_000
137
142
  const awaitPollMs = 1000
138
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
+
139
150
  /**
140
151
  * D0: the agent waits inside its turn for the person's next panel decision. Returns once the
141
152
  * helper phase changes (consent recorded, the last card answered, a Work moved out of Drop
142
- * needing a read) or a new card batch is due, else after `waitMs` with `changed: false`.
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).
143
158
  */
144
- async function awaitDecision(journeyId, waitMs, call, sleep) {
159
+ async function awaitDecision(journeyId, waitMs, call, sleep, ensureHost) {
145
160
  if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > maxAwaitMs)
146
161
  throw new Error('INVALID_ARGUMENTS')
147
162
  const started = Date.now()
148
163
  let view = await call('journey.get', { journeyId })
149
164
  const from = phaseOf(view)
165
+ const blockedFrom = blockedWorks(view).map((work) => work.workId)
150
166
  for (;;) {
151
167
  const phase = phaseOf(view)
152
168
  const batchDue = phase === 'browser-answers' && uncoveredPending(view).length > 0
153
- if (phase !== from || batchDue || Date.now() - started >= waitMs)
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)
154
177
  return {
155
178
  command: 'await',
156
179
  from,
157
180
  phase,
158
- changed: phase !== from || batchDue,
181
+ changed,
159
182
  waitedMs: Date.now() - started,
160
183
  revision: view.revision,
184
+ blockedWorks: blocked,
185
+ ...(retried.length ? { retriedWorks: retried, next: 'journey.prepare' } : {}),
186
+ ...(handoff ? { handoff: { panel: await ensureHost(), next: 'end-turn' } } : {}),
161
187
  }
162
188
  await sleep(Math.min(awaitPollMs, Math.max(0, waitMs - (Date.now() - started))))
163
189
  view = await call('journey.get', { journeyId })
@@ -165,7 +191,7 @@ async function awaitDecision(journeyId, waitMs, call, sleep) {
165
191
  }
166
192
 
167
193
  async function runPhaseCore(
168
- { run, journeyId, command, stage, refresh = false, limit = 3, waitMs = 0, sleep },
194
+ { run, journeyId, command, stage, refresh = false, limit = 3, waitMs = 0, sleep, ensureHost },
169
195
  call,
170
196
  ) {
171
197
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 16) throw new Error('INVALID_ARGUMENTS')
@@ -175,6 +201,10 @@ async function runPhaseCore(
175
201
  waitMs,
176
202
  call,
177
203
  sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
204
+ ensureHost ??
205
+ (() => {
206
+ throw new Error('PANEL_HOST_DETACH_FAILED')
207
+ }),
178
208
  )
179
209
  let view = await call('journey.get', { journeyId })
180
210
  if (command === 'scan-start')
@@ -202,6 +232,7 @@ async function runPhaseCore(
202
232
  revision: view.revision,
203
233
  readingComplete: view.readingComplete,
204
234
  pending: activeTerms(view).filter((term) => term.disposition === 'pending').length,
235
+ blockedWorks: blockedWorks(view),
205
236
  answersRevision: view.privacy.answersRevision,
206
237
  ...packagingProgress(view),
207
238
  coldElapsedMs,
@@ -224,14 +255,22 @@ async function runPhaseCore(
224
255
  const recovery = await recoverWrites(active.directory, manifest, call)
225
256
  // A failed transport leaves the exact pending request intact and never reaches refresh.
226
257
  if (recovery.recovered) view = await call('journey.get', { journeyId })
227
- if (recovery.stale && (!refresh || command !== 'tasks')) throw new Error('REVISION_CONFLICT')
228
- 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))) {
229
267
  const error = new Error(recovery.rejected)
230
268
  error.code = recovery.rejected
231
269
  throw error
232
270
  }
233
271
  if (recovery.completed === recovery.total && !recovery.stale && command === 'ingest') {
234
- 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)
235
274
  await publishCompleteClassification(
236
275
  manifest,
237
276
  view,
@@ -250,15 +289,15 @@ async function runPhaseCore(
250
289
  throw new Error('STAGE_NOT_READY')
251
290
  if (consolidationRefresh && (await optional(join(active?.directory ?? run, 'receipt.json'))))
252
291
  throw new Error('CLASSIFICATION_ALREADY_SUBMITTED')
253
- // A card batch (D3) whose append was accepted is finished: the next `tasks` starts another.
254
- const batchDone =
255
- stage === 'cards' &&
256
- manifest?.batch &&
257
- ['applied', 'replayed'].includes(
258
- (await optional(join(active.directory, 'receipt.json')))?.status,
259
- )
260
- if (batchDone && command !== 'tasks') return { stage, status: 'applied', batchDone: true }
261
- if (batchDone) manifest = null
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
262
301
  const fingerprint = stageFingerprint(view, stage)
263
302
  if (manifest?.resolutionBatches && !refresh)
264
303
  await assertResolutionFresh(active.directory, manifest, view)
@@ -277,10 +316,11 @@ async function runPhaseCore(
277
316
  const phase = phaseOf(view)
278
317
  const revisingCards =
279
318
  stage === 'cards' && refresh && ['browser-answers', 'answers-confirmation'].includes(phase)
280
- // Batches start while reading continues and after earlier batches (D3).
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.
281
321
  const cardBatch =
282
322
  stage === 'cards' &&
283
- !refresh &&
323
+ (!refresh || phase === 'scan') &&
284
324
  view.scope.status === 'confirmed' &&
285
325
  !['paused', 'sending', 'completed'].includes(phase) &&
286
326
  uncoveredPending(view).length > 0
@@ -335,6 +375,10 @@ async function runPhaseCore(
335
375
  fingerprint,
336
376
  tasks: stage === 'classification' ? [] : await createTasks(directory, stage, view, call),
337
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
+ : {}),
338
382
  }
339
383
  await save(join(directory, 'manifest.json'), manifest)
340
384
  await save(activePath, { directory })
@@ -434,36 +478,50 @@ async function runPhaseCore(
434
478
  }
435
479
  const key = stage === 'classification' ? 'items' : stage === 'cards' ? 'cards' : 'resolutions'
436
480
  const tasks = stage === 'classification' ? [manifest.consolidation] : manifest.tasks
437
- const request = {
438
- journeyId,
439
- expectedRevision: view.revision,
440
- idempotencyKey: randomUUID(),
441
- // After the known Works, scans keep moving the revision: bind to the classification (E2).
442
- ...(stage === 'classification' && manifest.partial
443
- ? { expectedClassificationRevision: manifest.partial.revision }
444
- : {}),
445
- ...(stage === 'classification'
446
- ? { [key]: await classificationSubmission(manifest, view) }
447
- : stage === 'cards'
448
- ? await cardSubmission(manifest)
449
- : { [key]: await answers(tasks, key) }),
450
- ...(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 }
451
487
  }
452
- await save(join(directory, 'request.json'), request)
453
488
  let result
454
- try {
455
- result = await call(procedureFor(stage), request)
456
- await save(join(directory, 'receipt.json'), {
457
- status: result.status,
458
- revision: result.state?.revision,
459
- })
460
- } catch (error) {
461
- 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)
462
508
  await save(join(directory, 'receipt.json'), {
463
- status: 'rejected',
464
- code: error.code,
509
+ status: result.status,
510
+ revision: result.state?.revision,
465
511
  })
466
- 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
467
525
  }
468
526
  if (result.status === 'stale') throw new Error('REVISION_CONFLICT')
469
527
  if (stage === 'classification' && !manifest.partial) {
@@ -476,6 +534,14 @@ async function runPhaseCore(
476
534
  /** Classification published known Works and is still grouping the remaining roots (E2). */
477
535
  const grouping = (view) => view.works.some((work) => work.placement === 'classifying')
478
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
+
479
545
  /**
480
546
  * E2: publishes known-project Works while unresolved roots are still being grouped, so their
481
547
  * scan can start. Its request and receipt are kept apart from the final proposal's.
@@ -575,6 +641,7 @@ export async function main(argv) {
575
641
  refresh: values.refresh,
576
642
  limit: values.limit === undefined ? 3 : Number(values.limit),
577
643
  waitMs: values['wait-ms'] === undefined ? 300_000 : Number(values['wait-ms']),
644
+ ensureHost: () => ensureDetachedHost(config.cli, config.journeyId),
578
645
  },
579
646
  call,
580
647
  ),
@@ -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