@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,40 +1,61 @@
1
1
  const types = ['work', 'side', 'coursework', 'personal', 'scratch', 'other']
2
2
  const access = ['public', 'private', 'drop']
3
+ const unknownField = (value, allowed) => {
4
+ const field = Object.keys(value).find((name) => !allowed(name))
5
+ return field && `unknown field ${field}`
6
+ }
3
7
  const text = (value, max) =>
4
8
  typeof value === 'string' && value.trim().length > 0 && value.length <= max
5
9
 
6
10
  export function validateClassificationJudgment(value, key) {
7
- if (
8
- !value ||
9
- value.rootKey !== key ||
10
- !text(value.projectName, 240) ||
11
- !text(value.workIntent, 240) ||
12
- !text(value.groupingBasis, 200) ||
13
- (value.targetProjectId !== undefined &&
14
- (typeof value.targetProjectId !== 'string' || !value.targetProjectId)) ||
15
- Object.keys(value).some(
16
- (field) =>
17
- !['rootKey', 'projectName', 'workIntent', 'groupingBasis', 'targetProjectId'].includes(
18
- field,
19
- ),
20
- )
21
- )
22
- throw new Error('CLASSIFICATION_JUDGMENT_INVALID')
11
+ const problem = !value
12
+ ? 'missing'
13
+ : value.rootKey !== key
14
+ ? 'rootKey does not match'
15
+ : !text(value.projectName, 240)
16
+ ? 'projectName must be 1-240 characters'
17
+ : !text(value.workIntent, 240)
18
+ ? 'workIntent must be 1-240 characters'
19
+ : !text(value.groupingBasis, 200)
20
+ ? 'groupingBasis must be 1-200 characters'
21
+ : value.targetProjectId !== undefined &&
22
+ (typeof value.targetProjectId !== 'string' || !value.targetProjectId)
23
+ ? 'targetProjectId must be a non-empty string'
24
+ : unknownField(value, (field) =>
25
+ [
26
+ 'rootKey',
27
+ 'projectName',
28
+ 'workIntent',
29
+ 'groupingBasis',
30
+ 'targetProjectId',
31
+ ].includes(field),
32
+ )
33
+ if (problem)
34
+ throw new Error('CLASSIFICATION_JUDGMENT_INVALID', {
35
+ cause: problem,
36
+ })
23
37
  }
24
38
 
25
39
  export function validateClassificationProject(value, projectId) {
26
- if (
27
- !value ||
28
- value.projectId !== projectId ||
29
- !text(value.description, 1000) ||
30
- !types.includes(value.projectType) ||
31
- !access.includes(value.recommendation?.access) ||
32
- !text(value.recommendation?.reason, 200) ||
33
- Object.keys(value).some(
34
- (field) => !['projectId', 'description', 'projectType', 'recommendation'].includes(field),
35
- )
36
- )
37
- throw new Error('PROJECT_METADATA_INVALID')
40
+ const problem = !value
41
+ ? 'missing'
42
+ : value.projectId !== projectId
43
+ ? 'projectId does not match'
44
+ : !text(value.description, 1000)
45
+ ? 'description must be 1-1000 characters'
46
+ : !types.includes(value.projectType)
47
+ ? `projectType must be one of ${types.join(', ')}`
48
+ : !access.includes(value.recommendation?.access)
49
+ ? `recommendation.access must be one of ${access.join(', ')}`
50
+ : !text(value.recommendation?.reason, 200)
51
+ ? 'recommendation.reason must be 1-200 characters'
52
+ : unknownField(value, (field) =>
53
+ ['projectId', 'description', 'projectType', 'recommendation'].includes(field),
54
+ )
55
+ if (problem)
56
+ throw new Error('PROJECT_METADATA_INVALID', {
57
+ cause: problem,
58
+ })
38
59
  }
39
60
 
40
61
  export function validateConsolidationAnswer(answer, descriptor) {
@@ -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
  })
@@ -1,17 +1,15 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { createHash } from 'node:crypto'
3
3
  import { answers } from './journey-tasks.mjs'
4
+ import { optional } from './journey-state.mjs'
4
5
  import {
5
6
  validateClassificationProject,
6
7
  validateConsolidationAnswer,
7
8
  compileAssignments,
8
9
  } from './journey-classification-validation.mjs'
9
10
 
10
- /** Expand short handles and fixed catalog membership only after exact coverage validation. */
11
- export async function classificationSubmission(manifest) {
12
- const classification = manifest.classification
13
- if (!classification?.complete || !manifest.consolidation)
14
- throw new Error('CONSOLIDATION_REQUIRED')
11
+ /** Known-project Works with their fixed catalog membership and answered metadata. */
12
+ async function knownProjects(classification, manifest) {
15
13
  const projectMetadata = new Map()
16
14
  for (const item of manifest.tasks.filter((entry) => entry.projectKeys)) {
17
15
  const results = await answers([item], 'items')
@@ -49,6 +47,40 @@ export async function classificationSubmission(manifest) {
49
47
  projectId: project.projectId,
50
48
  }
51
49
  })
50
+ return result
51
+ }
52
+
53
+ /**
54
+ * E2: once every known project has its metadata, its Works are published while the unresolved
55
+ * roots are still being grouped; they wait as `pendingRootSessionIds`. Null while metadata is
56
+ * missing or when nothing remains to group.
57
+ */
58
+ export async function partialClassificationSubmission(manifest) {
59
+ const classification = manifest.classification
60
+ if (
61
+ !classification?.complete ||
62
+ !classification.unknown.length ||
63
+ !classification.projects.length
64
+ )
65
+ return null
66
+ for (const item of manifest.tasks.filter((entry) => entry.projectKeys))
67
+ if (!(await optional(item.answer))) return null
68
+ const known = await knownProjects(classification, manifest)
69
+ const rootByKey = new Map(classification.roots.map((root) => [root.key, root.rootSessionId]))
70
+ const pendingRootSessionIds = classification.unknown.map((item) => {
71
+ const id = rootByKey.get(item.rootKey)
72
+ if (!id) throw new Error('CLASSIFICATION_ROOT_COVERAGE_INVALID')
73
+ return id
74
+ })
75
+ return { items: normalized(known), pendingRootSessionIds }
76
+ }
77
+
78
+ /** Expand short handles and fixed catalog membership only after exact coverage validation. */
79
+ export async function classificationSubmission(manifest) {
80
+ const classification = manifest.classification
81
+ if (!classification?.complete || !manifest.consolidation)
82
+ throw new Error('CONSOLIDATION_REQUIRED')
83
+ const result = await knownProjects(classification, manifest)
52
84
  let answerText
53
85
  let unknown
54
86
  if (manifest.consolidation.automatic) unknown = { groups: [], assignments: {} }
@@ -171,6 +203,11 @@ export async function classificationSubmission(manifest) {
171
203
  JSON.stringify(expectedRootIds) !== JSON.stringify(submittedRootIds)
172
204
  )
173
205
  throw new Error('CLASSIFICATION_ROOT_COVERAGE_INVALID')
206
+ return normalized(result)
207
+ }
208
+
209
+ /** Trimmed submission items; one project name never carries two different metadata sets. */
210
+ function normalized(result) {
174
211
  const normalizedNames = new Map()
175
212
  const output = result.map(({ projectId: _projectId, ...item }) => ({
176
213
  ...item,
@@ -7,3 +7,8 @@ export function consolidateClassification(
7
7
  export function classificationSubmission(
8
8
  manifest: Record<string, unknown>,
9
9
  ): Promise<Array<Record<string, unknown>>>
10
+
11
+ export function partialClassificationSubmission(manifest: Record<string, unknown>): Promise<{
12
+ items: Array<Record<string, unknown>>
13
+ pendingRootSessionIds: string[]
14
+ } | null>
@@ -221,4 +221,7 @@ export async function consolidateClassification(directory, manifest, { refresh =
221
221
  }
222
222
  }
223
223
 
224
- export { classificationSubmission } from './journey-consolidation-submit.mjs'
224
+ export {
225
+ classificationSubmission,
226
+ partialClassificationSubmission,
227
+ } from './journey-consolidation-submit.mjs'
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  export function save(path: string, value: unknown): Promise<void>
2
+ export function cardBatchOpen(run: string): Promise<boolean>
2
3
  export function hash(value: unknown): string
3
4
  export function definiteWriteRejection(error: unknown): boolean
4
5
  export function stageFingerprint(view: unknown, stage: string): string
@@ -36,7 +36,8 @@ export function phaseOf(view) {
36
36
  if (view.scope.status !== 'confirmed') return 'classification-confirmation'
37
37
  if (!view.readingComplete) return 'scan'
38
38
  const pending = activeTerms(view).some((term) => term.disposition === 'pending')
39
- if (pending && !view.privacy.cards.length) return 'cards'
39
+ // Card batches (D3) leave values no card covers yet for the next batch.
40
+ if (uncoveredPending(view).length) return 'cards'
40
41
  if (view.privacy.cards.length) {
41
42
  if (view.privacy.cards.some((card) => !card.answer)) return 'browser-answers'
42
43
  if (view.privacy.confirmedAnswersRevision !== view.privacy.answersRevision)
@@ -46,20 +47,51 @@ export function phaseOf(view) {
46
47
  const selected = view.works.filter((work) => work.access !== 'drop' && work.rootSessionIds.length)
47
48
  // Track B builds each Work's private packaging base in the background (ADR 0171).
48
49
  if (selected.some((work) => !work.staticReview)) return 'static-finishing'
49
- if (!view.privacy.packageApproval) return 'final-preview-and-confirmation'
50
- if (selected.some((work) => work.artifact?.revision !== work.revision)) return 'package'
50
+ // The person's Send click packages and uploads (D5); the agent has nothing left to run.
51
+ if (
52
+ !view.privacy.packageApproval ||
53
+ selected.some((work) => work.artifact?.revision !== work.revision)
54
+ )
55
+ return 'ready-to-send'
51
56
  return 'send'
52
57
  }
58
+ /**
59
+ * A card batch (E3) whose append is not yet accepted. While one is open, newly uncovered values
60
+ * wait for the next batch, so cards come as soon as they are needed without overlapping.
61
+ */
62
+ export async function cardBatchOpen(run) {
63
+ const active = await optional(join(run, 'cards-active.json'))
64
+ if (!active) return false
65
+ const manifest = await optional(join(active.directory, 'manifest.json'))
66
+ if (!manifest?.batch) return false
67
+ const receipt = await optional(join(active.directory, 'receipt.json'))
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)
70
+ }
71
+ /** Pending values that no published card covers yet: the input of the next card batch. */
72
+ export function uncoveredPending(view) {
73
+ const covered = new Set(view.privacy.cards.flatMap((card) => card.termKeys))
74
+ return activeTerms(view).filter(
75
+ (term) =>
76
+ term.disposition === 'pending' &&
77
+ !covered.has(term.key ?? JSON.stringify([term.type, term.value?.normalize('NFC')])),
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
+ */
53
84
  export function stageFingerprint(view, stage) {
85
+ const classification = stage === 'classification'
54
86
  return hash({
55
87
  snapshotId: view.snapshotId,
56
88
  guidance: view.guidanceDigest,
57
89
  works: view.works.map((work) => [
58
90
  work.id,
59
- work.inputRevision,
91
+ classification ? null : work.inputRevision,
60
92
  work.rootSessionIds,
61
- work.access,
62
- stage === 'classification'
93
+ classification ? null : work.access,
94
+ classification
63
95
  ? [
64
96
  work.title,
65
97
  work.projectName,
@@ -79,13 +79,15 @@ export async function answers(tasks, key) {
79
79
  }
80
80
  return entries
81
81
  }
82
- export async function createTasks(directory, stage, view, call) {
82
+ export async function createTasks(directory, stage, view, call, { batch = false } = {}) {
83
83
  const tasks = [],
84
84
  journeyId = view.journeyId
85
85
  const cards = await call('journey.cardsRead', { journeyId })
86
86
  const guidance = stage === 'resolution' ? await call('review.guidance', { journeyId }) : null
87
+ // A card batch drafts only values no published card covers yet (D3).
88
+ const covered = new Set(batch ? cards.cards.flatMap((card) => card.termKeys) : [])
87
89
  const pending = cards.terms
88
- .filter((term) => term.disposition === 'pending')
90
+ .filter((term) => term.disposition === 'pending' && !covered.has(term.key))
89
91
  .sort((a, b) => a.type.localeCompare(b.type))
90
92
  // Card drafting groups by meaning: a short ref replaces the long key, one excerpt replaces
91
93
  // full source evidence. The publisher maps refs back to exact keys.
@@ -120,7 +122,7 @@ export async function createTasks(directory, stage, view, call) {
120
122
  for (const group of groups) {
121
123
  const instruction =
122
124
  stage === 'cards'
123
- ? '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."
124
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.'
125
127
  const base = stage === 'cards' ? {} : { card: { ...group.card, termKeys: undefined }, guidance }
126
128
  const dataFor = (terms) => {
@@ -33,6 +33,10 @@ export function runPhase(
33
33
  stage?: string
34
34
  refresh?: boolean
35
35
  limit?: number
36
+ waitMs?: number
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 }>
36
40
  },
37
41
  call: (procedure: string, input: unknown) => Promise<unknown>,
38
42
  ): Promise<Record<string, unknown>>