@gitroll/tacitt-cli 0.0.0-stage → 0.1.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 (140) hide show
  1. package/README.md +84 -2
  2. package/bin/INDEX.md +9 -0
  3. package/bin/tacitt.cjs +16 -0
  4. package/dist/agent-setup-B85GaVIz.mjs +71 -0
  5. package/dist/agent-setup-worker.mjs +56 -0
  6. package/dist/cli.mjs +1343 -0
  7. package/dist/codex-title-worker.mjs +215 -0
  8. package/dist/configured-preview-worker.mjs +133 -0
  9. package/dist/configured-session-worker.mjs +117 -0
  10. package/dist/frame-D1j9R8xX.mjs +84 -0
  11. package/dist/heap-1FYhGds4.mjs +75 -0
  12. package/dist/heap-tfU61Ehy.mjs +7253 -0
  13. package/dist/job--TceKk0l.mjs +194 -0
  14. package/dist/locate-Bn32k2se.mjs +10647 -0
  15. package/dist/model-7Nu7szNP.mjs +41 -0
  16. package/dist/operation-executor.mjs +181 -0
  17. package/dist/preview-worker.mjs +22 -0
  18. package/dist/privacy-validator.mjs +46 -0
  19. package/dist/query-Da-KDbSV.mjs +14511 -0
  20. package/dist/run-CCATsHS2.mjs +916 -0
  21. package/dist/session-process.mjs +90 -0
  22. package/dist/src-BngGpFKd.mjs +16416 -0
  23. package/dist/src-CPPjOyHZ.mjs +12782 -0
  24. package/dist/src-DynBRDBc.mjs +6590 -0
  25. package/dist/store-database-worker.mjs +14 -0
  26. package/dist/web/THIRD_PARTY_LICENSES.md +388 -0
  27. package/dist/web/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  28. package/dist/web/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  29. package/dist/web/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  30. package/dist/web/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  31. package/dist/web/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  32. package/dist/web/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  33. package/dist/web/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  34. package/dist/web/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  35. package/dist/web/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  36. package/dist/web/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  37. package/dist/web/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  38. package/dist/web/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  39. package/dist/web/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  40. package/dist/web/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  41. package/dist/web/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  42. package/dist/web/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  43. package/dist/web/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  44. package/dist/web/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  45. package/dist/web/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  46. package/dist/web/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  47. package/dist/web/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
  48. package/dist/web/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
  49. package/dist/web/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff +0 -0
  50. package/dist/web/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 +0 -0
  51. package/dist/web/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
  52. package/dist/web/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
  53. package/dist/web/assets/ibm-plex-sans-cyrillic-700-normal-D_uavGvn.woff2 +0 -0
  54. package/dist/web/assets/ibm-plex-sans-cyrillic-700-normal-RFb1v-CK.woff +0 -0
  55. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
  56. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
  57. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 +0 -0
  58. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff +0 -0
  59. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
  60. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
  61. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-700-normal-BmRLXWpJ.woff2 +0 -0
  62. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-700-normal-DCQwxkRk.woff +0 -0
  63. package/dist/web/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
  64. package/dist/web/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
  65. package/dist/web/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff +0 -0
  66. package/dist/web/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 +0 -0
  67. package/dist/web/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
  68. package/dist/web/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
  69. package/dist/web/assets/ibm-plex-sans-greek-700-normal-CkZN0sRy.woff +0 -0
  70. package/dist/web/assets/ibm-plex-sans-greek-700-normal-wyJ6jzdA.woff2 +0 -0
  71. package/dist/web/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  72. package/dist/web/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  73. package/dist/web/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  74. package/dist/web/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  75. package/dist/web/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  76. package/dist/web/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  77. package/dist/web/assets/ibm-plex-sans-latin-700-normal-Bth3BMcD.woff +0 -0
  78. package/dist/web/assets/ibm-plex-sans-latin-700-normal-Bxkt5Cjx.woff2 +0 -0
  79. package/dist/web/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
  80. package/dist/web/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
  81. package/dist/web/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff +0 -0
  82. package/dist/web/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
  83. package/dist/web/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
  84. package/dist/web/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
  85. package/dist/web/assets/ibm-plex-sans-latin-ext-700-normal-DZH6Xh-4.woff +0 -0
  86. package/dist/web/assets/ibm-plex-sans-latin-ext-700-normal-aMgXseD4.woff2 +0 -0
  87. package/dist/web/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
  88. package/dist/web/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
  89. package/dist/web/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff +0 -0
  90. package/dist/web/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 +0 -0
  91. package/dist/web/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
  92. package/dist/web/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
  93. package/dist/web/assets/ibm-plex-sans-vietnamese-700-normal-BV5B8Are.woff +0 -0
  94. package/dist/web/assets/ibm-plex-sans-vietnamese-700-normal-CkEo6LDy.woff2 +0 -0
  95. package/dist/web/assets/index-CWQ05PFl.js +67 -0
  96. package/dist/web/assets/index-J-AWt_cW.css +1 -0
  97. package/dist/web/index.html +14 -0
  98. package/package.json +56 -3
  99. package/skills/INDEX.md +9 -0
  100. package/skills/tacitt/INDEX.md +11 -0
  101. package/skills/tacitt/SKILL.md +42 -0
  102. package/skills/tacitt/references/INDEX.md +15 -0
  103. package/skills/tacitt/references/b-method.md +22 -0
  104. package/skills/tacitt/references/bootstrap.md +109 -0
  105. package/skills/tacitt/references/classification.md +83 -0
  106. package/skills/tacitt/references/commands.md +147 -0
  107. package/skills/tacitt/references/journey.md +52 -0
  108. package/skills/tacitt/references/parallel-review.md +64 -0
  109. package/skills/tacitt/references/send.md +30 -0
  110. package/skills/tacitt/scripts/INDEX.md +43 -0
  111. package/skills/tacitt/scripts/answer.mjs +257 -0
  112. package/skills/tacitt/scripts/describe.mjs +55 -0
  113. package/skills/tacitt/scripts/journey-card-consolidation.d.mts +8 -0
  114. package/skills/tacitt/scripts/journey-card-consolidation.mjs +146 -0
  115. package/skills/tacitt/scripts/journey-classification-metadata.d.mts +16 -0
  116. package/skills/tacitt/scripts/journey-classification-metadata.mjs +111 -0
  117. package/skills/tacitt/scripts/journey-classification-progress.mjs +194 -0
  118. package/skills/tacitt/scripts/journey-classification-tasks.d.mts +18 -0
  119. package/skills/tacitt/scripts/journey-classification-tasks.mjs +345 -0
  120. package/skills/tacitt/scripts/journey-classification-validation.d.mts +20 -0
  121. package/skills/tacitt/scripts/journey-classification-validation.mjs +219 -0
  122. package/skills/tacitt/scripts/journey-classification.mjs +145 -0
  123. package/skills/tacitt/scripts/journey-consolidation-submit.mjs +190 -0
  124. package/skills/tacitt/scripts/journey-consolidation.d.mts +9 -0
  125. package/skills/tacitt/scripts/journey-consolidation.mjs +224 -0
  126. package/skills/tacitt/scripts/journey-scan-timing.mjs +143 -0
  127. package/skills/tacitt/scripts/journey-state.d.mts +42 -0
  128. package/skills/tacitt/scripts/journey-state.mjs +268 -0
  129. package/skills/tacitt/scripts/journey-tasks.d.mts +26 -0
  130. package/skills/tacitt/scripts/journey-tasks.mjs +183 -0
  131. package/skills/tacitt/scripts/journey.d.mts +38 -0
  132. package/skills/tacitt/scripts/journey.mjs +451 -0
  133. package/skills/tacitt/scripts/local-cli.d.mts +18 -0
  134. package/skills/tacitt/scripts/local-cli.mjs +116 -0
  135. package/skills/tacitt/scripts/privacy-validator.mjs +10350 -0
  136. package/skills/tacitt/scripts/review-stream-client.d.mts +22 -0
  137. package/skills/tacitt/scripts/review-stream-client.mjs +194 -0
  138. package/skills/tacitt/scripts/review.d.mts +87 -0
  139. package/skills/tacitt/scripts/review.mjs +1589 -0
  140. package/skills/tacitt/scripts/verify-cli.mjs +10 -0
@@ -0,0 +1,451 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdir, realpath } from 'node:fs/promises'
3
+ import { join, resolve, isAbsolute } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { parseArgs } from 'node:util'
6
+ import { main as review, withJourneyCli } from './review.mjs'
7
+ import {
8
+ read,
9
+ optional,
10
+ save,
11
+ phaseOf,
12
+ activeTerms,
13
+ stageFingerprint,
14
+ procedureFor,
15
+ recoverWrites,
16
+ definiteWriteRejection,
17
+ resolutionAuthority,
18
+ resolutionEvidence,
19
+ planResolutions,
20
+ assertResolutionFresh,
21
+ } from './journey-state.mjs'
22
+ import { classificationTasks } from './journey-classification-tasks.mjs'
23
+ import { classificationEvidence, classificationFormat } from './journey-classification.mjs'
24
+ import { consolidateClassification, classificationSubmission } from './journey-consolidation.mjs'
25
+ import { consolidateCards, cardSubmission } from './journey-card-consolidation.mjs'
26
+ import {
27
+ classificationProgress,
28
+ publishClassificationProgress,
29
+ publishFailedClassificationProgress,
30
+ } from './journey-classification-progress.mjs'
31
+ import { answers, createTasks } from './journey-tasks.mjs'
32
+ import { scanTiming } from './journey-scan-timing.mjs'
33
+ export { phaseOf, stageFingerprint } from './journey-state.mjs'
34
+
35
+ async function publishCompleteClassification(manifest, view, call, proposalRevision) {
36
+ const baselineRevision = manifest.classification?.baselineRevision
37
+ if (
38
+ !Number.isSafeInteger(baselineRevision) ||
39
+ view.classificationRevision !== view.revision ||
40
+ view.classificationRevision <= baselineRevision
41
+ )
42
+ throw new Error('CLASSIFICATION_PROGRESS_COMPLETION_UNCONFIRMED')
43
+ if (proposalRevision !== view.revision)
44
+ throw new Error('CLASSIFICATION_PROGRESS_COMPLETION_UNCONFIRMED')
45
+ await publishClassificationProgress(manifest, view, call, {
46
+ status: 'complete',
47
+ stage: 'proposal',
48
+ proposalRevision,
49
+ })
50
+ }
51
+
52
+ // Longer than the default request bound, still inside a 2-minute tool timeout.
53
+ const FREEZE_REQUEST_MS = 100_000
54
+
55
+ async function prepareInitialFreeze(run, journeyId, call, view) {
56
+ const path = join(run, 'initial-freeze-timing.json')
57
+ const timing = await optional(path)
58
+ if (!view.consent || view.phase === 'paused' || view.scope?.status === 'confirmed')
59
+ throw new Error('INITIAL_FREEZE_NOT_ALLOWED')
60
+ if (timing?.completedAt && timing.snapshotId !== view.snapshotId)
61
+ throw new Error('INITIAL_FREEZE_SNAPSHOT_CHANGED')
62
+ if (timing?.completedAt && timing.snapshotId === view.snapshotId)
63
+ return {
64
+ stage: 'freeze',
65
+ status: 'complete',
66
+ ...timing,
67
+ elapsedMs: Date.parse(timing.completedAt) - Date.parse(timing.startedAt),
68
+ }
69
+ if (view.snapshotId && (!timing?.startedAt || view.classificationRevision !== null))
70
+ throw new Error('INITIAL_FREEZE_NOT_STARTED_HERE')
71
+ const startedAt = timing?.startedAt ?? new Date().toISOString()
72
+ if (!timing) await save(path, { startedAt, completedAt: null })
73
+ // Safe on retry while scope remains pending; journey.prepare resumes the frozen capture.
74
+ try {
75
+ await call('journey.prepare', { journeyId }, { timeoutMs: FREEZE_REQUEST_MS })
76
+ } catch (error) {
77
+ // A large first freeze can outlast one helper command; captured roots persist for the rerun.
78
+ if (error?.code === 'CLI_UNAVAILABLE') return { stage: 'freeze', status: 'running', startedAt }
79
+ throw error
80
+ }
81
+ const completed = await call('journey.get', { journeyId })
82
+ if (
83
+ !completed.snapshotId ||
84
+ completed.scope?.status === 'confirmed' ||
85
+ completed.classificationRevision !== null
86
+ )
87
+ throw new Error('INITIAL_FREEZE_NOT_CONFIRMED')
88
+ const completedAt = new Date().toISOString()
89
+ const saved = { startedAt, completedAt, snapshotId: completed.snapshotId }
90
+ await save(path, saved)
91
+ return {
92
+ stage: 'freeze',
93
+ status: 'complete',
94
+ ...saved,
95
+ elapsedMs: Date.parse(completedAt) - Date.parse(startedAt),
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Scan timing is an internal diagnostic (BL-180): a receipt that no longer matches the journey
101
+ * marks timing stale or unavailable and never hides the rest of the helper's report.
102
+ */
103
+ async function observeScanTiming(run, journeyId, view, start, call) {
104
+ try {
105
+ return await scanTiming(run, journeyId, view, start, call)
106
+ } catch (error) {
107
+ const code = error instanceof Error ? error.message : ''
108
+ if (!code.startsWith('SCAN_TIMING_')) throw error
109
+ process.stderr.write(code + '\n')
110
+ return { status: code === 'SCAN_TIMING_SCOPE_CHANGED' ? 'stale' : 'unavailable', code }
111
+ }
112
+ }
113
+
114
+ /** Locked by withJourneyCli in production; injected transport permits deterministic crash tests. */
115
+ /** A running journey render (panel or CLI) with packaged/selected Work counts (BL-185). */
116
+ function packagingProgress(view) {
117
+ if (!view.packaging) return {}
118
+ const selected = view.works.filter((work) => work.access !== 'drop' && work.rootSessionIds.length)
119
+ 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
+ },
127
+ }
128
+ }
129
+
130
+ async function runPhaseCore({ run, journeyId, command, stage, refresh = false, limit = 3 }, call) {
131
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 16) throw new Error('INVALID_ARGUMENTS')
132
+ let view = await call('journey.get', { journeyId })
133
+ if (command === 'scan-start')
134
+ return {
135
+ scan: await observeScanTiming(run, journeyId, view, true, call),
136
+ next: 'journey.prepare',
137
+ }
138
+ if (command === 'status') {
139
+ const active = await optional(join(run, 'classification-active.json'))
140
+ const manifest = active ? await optional(join(active.directory, 'manifest.json')) : null
141
+ const freeze = await optional(join(run, 'initial-freeze-timing.json'))
142
+ const proposalAt =
143
+ view.classificationProgress?.status === 'complete'
144
+ ? view.classificationProgress.updatedAt
145
+ : null
146
+ const coldElapsedMs =
147
+ freeze?.completedAt && freeze.snapshotId === view.snapshotId && proposalAt
148
+ ? Math.max(0, Date.parse(proposalAt) - Date.parse(freeze.startedAt))
149
+ : null
150
+ return {
151
+ ...(manifest?.classification
152
+ ? { classification: await classificationProgress(manifest) }
153
+ : {}),
154
+ phase: phaseOf(view),
155
+ revision: view.revision,
156
+ readingComplete: view.readingComplete,
157
+ pending: activeTerms(view).filter((term) => term.disposition === 'pending').length,
158
+ answersRevision: view.privacy.answersRevision,
159
+ ...packagingProgress(view),
160
+ coldElapsedMs,
161
+ scan: await observeScanTiming(run, journeyId, view, false, call),
162
+ }
163
+ }
164
+ if (command === 'prepare') return prepareInitialFreeze(run, journeyId, call, view)
165
+ if (
166
+ !['classification', 'cards', 'resolution'].includes(stage) ||
167
+ !['tasks', 'evidence', 'consolidate', 'ingest'].includes(command)
168
+ )
169
+ throw new Error('INVALID_ARGUMENTS')
170
+ const activePath = join(run, `${stage}-active.json`)
171
+ const active = await optional(activePath)
172
+ let manifest = active ? await optional(join(active.directory, 'manifest.json')) : null
173
+ if (active && !manifest) throw new Error('CORRUPTED_STATE')
174
+ if (manifest && manifest.format !== classificationFormat)
175
+ throw new Error('INCOMPATIBLE_JOURNEY_TASKS')
176
+ if (manifest) {
177
+ const recovery = await recoverWrites(active.directory, manifest, call)
178
+ // A failed transport leaves the exact pending request intact and never reaches refresh.
179
+ 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')) {
182
+ const error = new Error(recovery.rejected)
183
+ error.code = recovery.rejected
184
+ throw error
185
+ }
186
+ if (recovery.completed === recovery.total && !recovery.stale && command === 'ingest') {
187
+ if (stage === 'classification' && manifest.consolidation)
188
+ await publishCompleteClassification(
189
+ manifest,
190
+ view,
191
+ call,
192
+ (await optional(join(active.directory, 'receipt.json')))?.revision,
193
+ )
194
+ return { stage, ...recovery }
195
+ }
196
+ }
197
+ const consolidationRefresh = refresh && command === 'consolidate' && stage === 'classification'
198
+ if (
199
+ consolidationRefresh &&
200
+ !['classification', 'classification-confirmation'].includes(phaseOf(view))
201
+ )
202
+ throw new Error('STAGE_NOT_READY')
203
+ if (consolidationRefresh && (await optional(join(active?.directory ?? run, 'receipt.json'))))
204
+ throw new Error('CLASSIFICATION_ALREADY_SUBMITTED')
205
+ const fingerprint = stageFingerprint(view, stage)
206
+ if (manifest?.resolutionBatches && !refresh)
207
+ await assertResolutionFresh(active.directory, manifest, view)
208
+ else if (manifest && manifest.fingerprint !== fingerprint && (!refresh || consolidationRefresh))
209
+ throw new Error('STALE_TASKS')
210
+ if (refresh && command !== 'tasks' && !consolidationRefresh) throw new Error('INVALID_ARGUMENTS')
211
+ if (command === 'tasks') {
212
+ const phase = phaseOf(view)
213
+ const revisingCards =
214
+ stage === 'cards' && refresh && ['browser-answers', 'answers-confirmation'].includes(phase)
215
+ if (
216
+ phase !== stage &&
217
+ !revisingCards &&
218
+ !(stage === 'classification' && phase === 'classification-confirmation')
219
+ )
220
+ throw new Error('STAGE_NOT_READY')
221
+ if (manifest && !refresh && stage === 'classification') {
222
+ if (!manifest.classification && manifest.format === classificationFormat)
223
+ throw new Error('INCOMPATIBLE_CLASSIFICATION_TASKS')
224
+ const result = await classificationTasks(active.directory, manifest, view, call, limit)
225
+ await publishClassificationProgress(manifest, view, call)
226
+ return result
227
+ }
228
+ if (manifest && !refresh)
229
+ return {
230
+ stage,
231
+ tasks: manifest.tasks,
232
+ consolidation: manifest.consolidation ?? null,
233
+ reused: true,
234
+ }
235
+ const directory = join(run, stage, `${fingerprint}-${randomUUID()}`)
236
+ await mkdir(directory, { recursive: true, mode: 0o700 })
237
+ manifest = {
238
+ format: classificationFormat,
239
+ stage,
240
+ fingerprint,
241
+ tasks: stage === 'classification' ? [] : await createTasks(directory, stage, view, call),
242
+ answersRevision: view.privacy.answersRevision,
243
+ }
244
+ await save(join(directory, 'manifest.json'), manifest)
245
+ await save(activePath, { directory })
246
+ if (stage === 'classification') {
247
+ const result = await classificationTasks(directory, manifest, view, call, limit)
248
+ await publishClassificationProgress(manifest, view, call)
249
+ return result
250
+ }
251
+ return { stage, tasks: manifest.tasks }
252
+ }
253
+ if (!manifest || !active) throw new Error('TASKS_REQUIRED')
254
+ const directory = active.directory
255
+ if (command === 'evidence') {
256
+ if (stage !== 'classification') throw new Error('INVALID_ARGUMENTS')
257
+ const result = await classificationEvidence(directory, manifest, view, call, limit)
258
+ await publishClassificationProgress(manifest, view, call)
259
+ return result
260
+ }
261
+ if (command === 'consolidate') {
262
+ if (stage === 'cards') return consolidateCards(directory, manifest)
263
+ if (stage !== 'classification') throw new Error('INVALID_ARGUMENTS')
264
+ if (!manifest.classification?.complete) throw new Error('CLASSIFICATION_READS_INCOMPLETE')
265
+ let pendingRootAnswers = false
266
+ for (const item of manifest.tasks)
267
+ if (!item.projectKeys && !(await optional(item.answer))) pendingRootAnswers = true
268
+ if (pendingRootAnswers) throw new Error('CLASSIFICATION_ANSWERS_INCOMPLETE')
269
+ const evidence = await classificationEvidence(directory, manifest, view, call, limit)
270
+ await publishClassificationProgress(manifest, view, call)
271
+ if (!evidence.evidenceComplete || evidence.tasks.length)
272
+ return { ...evidence, consolidationReady: false }
273
+ const result = await consolidateClassification(directory, manifest, {
274
+ refresh: consolidationRefresh,
275
+ })
276
+ await publishClassificationProgress(manifest, view, call, {
277
+ stage: 'proposal',
278
+ })
279
+ return result
280
+ }
281
+ if (stage === 'resolution') {
282
+ if (!manifest.resolutionBatches) {
283
+ manifest.resolutionBatches = planResolutions(
284
+ view,
285
+ await answers(manifest.tasks, 'resolutions'),
286
+ )
287
+ manifest.resolutionAuthority = resolutionAuthority(view)
288
+ manifest.resolutionEvidence = resolutionEvidence(view)
289
+ await save(join(directory, 'manifest.json'), manifest)
290
+ }
291
+ let recorded = 0
292
+ for (const batch of manifest.resolutionBatches) {
293
+ const requestPath = join(directory, `${batch.id}.request.json`),
294
+ receiptPath = join(directory, `${batch.id}.receipt.json`)
295
+ if (await optional(receiptPath)) continue
296
+ const latest = await call('journey.get', { journeyId })
297
+ await assertResolutionFresh(directory, manifest, latest)
298
+ const request = {
299
+ journeyId,
300
+ expectedRevision: latest.revision,
301
+ idempotencyKey: randomUUID(),
302
+ answersRevision: manifest.answersRevision,
303
+ resolutions: batch.resolutions,
304
+ }
305
+ await save(requestPath, request)
306
+ const result = await call(procedureFor(stage), request)
307
+ await save(receiptPath, {
308
+ status: result.status,
309
+ revision: result.state?.revision,
310
+ })
311
+ if (result.status === 'stale') throw new Error('REVISION_CONFLICT')
312
+ recorded++
313
+ }
314
+ return {
315
+ stage,
316
+ recorded,
317
+ completed: manifest.resolutionBatches.length,
318
+ total: manifest.resolutionBatches.length,
319
+ }
320
+ }
321
+ if (stage === 'classification' && !manifest.consolidation)
322
+ throw new Error('CONSOLIDATION_REQUIRED')
323
+ if (stage === 'classification') {
324
+ const readiness = await consolidateClassification(directory, manifest)
325
+ if (!readiness.readyForIngest) return readiness
326
+ }
327
+ if (stage === 'cards') {
328
+ const readiness = await consolidateCards(directory, manifest)
329
+ if (!readiness.readyForIngest) return readiness
330
+ }
331
+ const key = stage === 'classification' ? 'items' : stage === 'cards' ? 'cards' : 'resolutions'
332
+ 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 } : {}),
344
+ }
345
+ await save(join(directory, 'request.json'), request)
346
+ 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))
355
+ await save(join(directory, 'receipt.json'), {
356
+ status: 'rejected',
357
+ code: error.code,
358
+ })
359
+ throw error
360
+ }
361
+ if (result.status === 'stale') throw new Error('REVISION_CONFLICT')
362
+ if (stage === 'classification') {
363
+ const latest = await call('journey.get', { journeyId })
364
+ await publishCompleteClassification(manifest, latest, call, result.state?.revision)
365
+ }
366
+ return { stage, status: result.status, revision: result.state?.revision }
367
+ }
368
+
369
+ const terminalClassificationErrors = new Set([
370
+ 'INVALID_SELECTION',
371
+ 'CLASSIFICATION_PROJECT_NAME_CONFLICT',
372
+ 'CLASSIFICATION_ROOT_COVERAGE_INVALID',
373
+ 'CLASSIFICATION_THEME_TOO_SMALL',
374
+ 'CLASSIFICATION_UNSORTED_DUPLICATE',
375
+ 'INVALID_CLASSIFICATION_GROUP_KIND',
376
+ 'INVALID_CLASSIFICATION_ITEM',
377
+ ])
378
+
379
+ /** Preserve the original error; publish failure only against a still-current running checkpoint. */
380
+ export async function runPhase(args, call) {
381
+ try {
382
+ return await runPhaseCore(args, call)
383
+ } catch (error) {
384
+ const code = error?.code ?? error?.message
385
+ if (args.stage === 'classification' && terminalClassificationErrors.has(code)) {
386
+ try {
387
+ const active = await optional(join(args.run, 'classification-active.json'))
388
+ const manifest = active ? await optional(join(active.directory, 'manifest.json')) : null
389
+ if (manifest?.format === classificationFormat && manifest.classification) {
390
+ const latest = await call('journey.get', {
391
+ journeyId: args.journeyId,
392
+ })
393
+ await publishFailedClassificationProgress(manifest, latest, call)
394
+ }
395
+ } catch {
396
+ // Progress is advisory; never mask the classification error that caused this path.
397
+ }
398
+ }
399
+ throw error
400
+ }
401
+ }
402
+
403
+ export async function main(argv) {
404
+ const { values, positionals } = parseArgs({
405
+ args: argv,
406
+ allowPositionals: true,
407
+ options: {
408
+ run: { type: 'string' },
409
+ journey: { type: 'string' },
410
+ stage: { type: 'string' },
411
+ refresh: { type: 'boolean', default: false },
412
+ limit: { type: 'string' },
413
+ model: { type: 'string' },
414
+ effort: { type: 'string' },
415
+ },
416
+ })
417
+ const [command] = positionals
418
+ if (!values.run) throw new Error('INVALID_ARGUMENTS')
419
+ if (command === 'init') {
420
+ const cli = positionals.slice(1)
421
+ const scopes = cli.flatMap((value, index) => (value === '--state-dir' ? [cli[index + 1]] : []))
422
+ if (scopes.length !== 1 || typeof scopes[0] !== 'string' || !isAbsolute(scopes[0]))
423
+ throw new Error('EXPLICIT_CLI_STATE_DIRECTORY_REQUIRED')
424
+ return review(argv)
425
+ }
426
+ const run = resolve(values.run),
427
+ config = await read(join(run, 'config.json'))
428
+ return withJourneyCli(run, (call) =>
429
+ runPhase(
430
+ {
431
+ run,
432
+ journeyId: config.journeyId,
433
+ command,
434
+ stage: values.stage,
435
+ refresh: values.refresh,
436
+ limit: values.limit === undefined ? 3 : Number(values.limit),
437
+ },
438
+ call,
439
+ ),
440
+ )
441
+ }
442
+ const entry = process.argv[1]
443
+ if (entry && (await realpath(entry)) === fileURLToPath(import.meta.url))
444
+ main(process.argv.slice(2)).then(
445
+ (result) => process.stdout.write(JSON.stringify({ ok: true, ...result }) + '\n'),
446
+ (error) => {
447
+ const code = /^[A-Z_]{3,80}$/u.test(error.message) ? error.message : 'JOURNEY_HELPER_FAILED'
448
+ process.stderr.write(code + '\n')
449
+ process.exitCode = 1
450
+ },
451
+ )
@@ -0,0 +1,18 @@
1
+ export interface LocalCliBinding {
2
+ schemaVersion: 1
3
+ package: '@gitroll/tacitt-cli'
4
+ version: string
5
+ nodePath: string
6
+ cliPath: string
7
+ artifactSha256: string
8
+ }
9
+
10
+ export function readLocalCliBinding(
11
+ cliPath: string,
12
+ expected: { name: string; version: string },
13
+ ): Promise<LocalCliBinding>
14
+
15
+ export function verifyLocalCliBinding(
16
+ receiptPath: string,
17
+ expectedReceiptSha256: string,
18
+ ): Promise<LocalCliBinding>
@@ -0,0 +1,116 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { lstat, open, readdir, realpath } from 'node:fs/promises'
3
+ import { dirname, isAbsolute, join } from 'node:path'
4
+
5
+ const fail = () => {
6
+ throw new Error('LOCAL_CLI_BINDING_INVALID')
7
+ }
8
+ const object = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
9
+ const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
10
+ const digestPattern = /^[a-f0-9]{64}$/
11
+ const digest = (bytes) => createHash('sha256').update(bytes).digest('hex')
12
+
13
+ async function regularFile(path, limit) {
14
+ const before = await lstat(path)
15
+ if (!before.isFile() || before.isSymbolicLink() || before.size > limit) fail()
16
+ const handle = await open(path, 'r')
17
+ try {
18
+ const opened = await handle.stat()
19
+ if (opened.dev !== before.dev || opened.ino !== before.ino) fail()
20
+ const bytes = await handle.readFile()
21
+ const after = await lstat(path)
22
+ if (
23
+ !after.isFile() ||
24
+ after.isSymbolicLink() ||
25
+ after.dev !== before.dev ||
26
+ after.ino !== before.ino ||
27
+ after.size !== before.size ||
28
+ after.mtimeMs !== before.mtimeMs ||
29
+ bytes.length !== before.size
30
+ )
31
+ fail()
32
+ return bytes
33
+ } finally {
34
+ await handle.close()
35
+ }
36
+ }
37
+
38
+ async function packageFiles(root) {
39
+ const files = []
40
+ let bytes = 0
41
+ const visit = async (name, depth = 0) => {
42
+ if (depth > 16 || files.length >= 10000) fail()
43
+ const path = join(root, name)
44
+ const info = await lstat(path)
45
+ if (info.isSymbolicLink()) fail()
46
+ if (info.isDirectory()) {
47
+ for (const child of (await readdir(path)).sort()) await visit(`${name}/${child}`, depth + 1)
48
+ } else if (info.isFile()) {
49
+ const content = await regularFile(path, 512 * 1024 * 1024 - bytes)
50
+ bytes += content.length
51
+ files.push([name, content.length, digest(content)])
52
+ } else fail()
53
+ }
54
+ for (const name of ['package.json', 'bin', 'dist', 'skills']) await visit(name)
55
+ return files
56
+ }
57
+
58
+ /** Reads only the selected installed package; it never downloads or opens application state. */
59
+ export async function readLocalCliBinding(cliPath, expected) {
60
+ if (
61
+ typeof cliPath !== 'string' ||
62
+ !isAbsolute(cliPath) ||
63
+ cliPath
64
+ .split('')
65
+ .some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) ||
66
+ !object(expected) ||
67
+ expected.name !== '@gitroll/tacitt-cli' ||
68
+ typeof expected.version !== 'string' ||
69
+ !versionPattern.test(expected.version)
70
+ )
71
+ fail()
72
+ const requested = await lstat(cliPath)
73
+ if (!requested.isFile() || requested.isSymbolicLink()) fail()
74
+ const executable = await realpath(cliPath)
75
+ const root = dirname(dirname(executable))
76
+ if (executable !== join(root, 'bin', 'tacitt.cjs')) fail()
77
+ const metadataBytes = await regularFile(join(root, 'package.json'), 1024 * 1024)
78
+ const metadata = JSON.parse(metadataBytes.toString('utf8'))
79
+ if (
80
+ !object(metadata) ||
81
+ metadata.name !== expected.name ||
82
+ metadata.version !== expected.version ||
83
+ !object(metadata.bin) ||
84
+ metadata.bin['tacitt'] !== 'bin/tacitt.cjs'
85
+ )
86
+ fail()
87
+ const files = await packageFiles(root)
88
+ if (
89
+ !files.some(([name]) => name === 'dist/cli.mjs') ||
90
+ !files.some(([name, , sha256]) => name === 'package.json' && sha256 === digest(metadataBytes))
91
+ )
92
+ fail()
93
+ return {
94
+ schemaVersion: 1,
95
+ package: expected.name,
96
+ version: expected.version,
97
+ nodePath: await realpath(process.execPath),
98
+ cliPath: executable,
99
+ artifactSha256: digest(JSON.stringify(files)),
100
+ }
101
+ }
102
+
103
+ /** A receipt hash comes from the managed entry, independently of the local receipt bytes. */
104
+ export async function verifyLocalCliBinding(receiptPath, expectedReceiptSha256) {
105
+ if (!isAbsolute(receiptPath) || !digestPattern.test(expectedReceiptSha256)) fail()
106
+ const bytes = await regularFile(receiptPath, 8192)
107
+ if (digest(bytes) !== expectedReceiptSha256) fail()
108
+ const receipt = JSON.parse(bytes.toString('utf8'))
109
+ if (!object(receipt) || receipt.schemaVersion !== 1) fail()
110
+ const actual = await readLocalCliBinding(receipt.cliPath, {
111
+ name: receipt.package,
112
+ version: receipt.version,
113
+ })
114
+ if (JSON.stringify(actual) !== JSON.stringify(receipt)) fail()
115
+ return actual
116
+ }