@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,224 @@
1
+ import { join } from 'node:path'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { optional, save } from './journey-state.mjs'
5
+ import { answers, task, taskTokens } from './journey-tasks.mjs'
6
+ import {
7
+ validateClassificationJudgment,
8
+ validateClassificationProject,
9
+ } from './journey-classification-validation.mjs'
10
+ import { createMetadataTasks } from './journey-classification-metadata.mjs'
11
+
12
+ const budget = 90000
13
+ const instruction =
14
+ 'You own the semantic audit and reconciliation of every supplied candidate/root; Main only dispatches this generated task and runs CLI validation/ingestion. Read every task range, including all root intents, evidence and context, then author and publish the complete answer yourself. Reconcile candidate buckets globally into final Works, not one final group per input bucket. Input size is not output size: provide only a compact group catalog and one explicit candidate-key-to-group-ID assignment for every candidate. Before proposing a new project, topic or Unsorted, compare every candidate/root against every entry in catalog. Merge equivalent supported identities across differently named candidates and attach proven existing projects using the exact catalog targetProjectId. A root with a targetProjectId has a fixed identity constraint: it must finish in a group with that same targetProjectId, including after overrides; do not redirect it or erase that target. Other roots may be attached to its group when supported. Assign miscellaneous candidates to one Unsorted; retain a topic only when at least three roots genuinely share it. Return ONLY {groups:[{id,projectName,groupKind,targetProjectId?}],assignments:{candidateKey:groupId,...},overrides?:[{rootKey,groupId}]}. Assignment keys must be the exact supplied candidate.key strings; include each exactly once and no other keys. Do not use positional arrays. Do not include description, projectType, recommendation, privacy rationale or other fields. An assignment initially applies to every root in that candidate; overrides split homonyms or path conflicts but cannot violate an existing targetProjectId constraint. Do not merge identities merely because names match. Every supplied root must end in exactly one group. Do not include root keys in groups or invent identities. Known projects attach with exact targetProjectId. The rootContext rows include CLI facts and bounded partial projectEvidence. Partial references are positive evidence, not completeness proof; missing evidence is not negative proof. Checking, using, connecting to, or integrating with a product/tool does not prove that a root develops that product’s repository; require independent repository or content evidence before assigning product project identity. A product topic may remain when roots genuinely share it. A scratch workspace name alone is not project evidence, even if descriptive; however do not ignore exact repository references or independent content evidence. Reject unsupported project or topic claims in workIntent or groupingBasis that merely reinterpret a generated scratch/temp name or copy a sibling’s activity. Missing root-specific intent must remain unknown; a coherent topic requires positive evidence for that root’s membership, otherwise use Unsorted. Do not turn an uncertain initial guess into evidence by repeating it. Before publishing, audit every candidate with workspaceKind folder separately from scratch/temp: a supported named ordinary-folder project is groupKind project, including a singleton; lack of Git metadata does not downgrade it to topic or Unsorted. The known catalog is not exhaustive. Before assigning Unsorted, audit every root with repository references, changes, or an ordinary named folder, together with its sibling roots: an independently evidenced new repository can form its own project even with one root. Missing catalog membership is not negative evidence. Before publishing, compare every retained new group with known project aliases and product-specific task intent so connection checks and supporting work are not split from their supported parent project. Do not use repository reference counts, change counts or apparent dominance as a majority vote for genuinely cross-project roots. Ambiguous or incomplete cross-project evidence needs direct owning intent before adding a targetProjectId; otherwise preserve uncertainty and leave the root unbound. Existing fixed targets remain binding; do not invent corroboration for them. Keep clear projects independent. CLI combines topics with fewer than three final roots and miscellaneous roots into Unsorted. Preserve clear projects with one root. Do not omit roots or force unrelated roots into themes. Names <=240 chars. This is a proposal, never approval.'
15
+
16
+ function dispatchDescriptor(item) {
17
+ return {
18
+ id: item.id,
19
+ path: item.path,
20
+ answer: item.answer,
21
+ descriptor: item.descriptor,
22
+ prompt: item.prompt,
23
+ inputHash: item.inputHash,
24
+ }
25
+ }
26
+
27
+ const formatPayload = (data) => {
28
+ const candidates = data.candidates.map(
29
+ (candidate) =>
30
+ `{${Object.entries(candidate)
31
+ .map(
32
+ ([key, value]) =>
33
+ `${JSON.stringify(key)}:${key === 'roots' ? `[\n${value.map((row) => JSON.stringify(row)).join(',\n')}\n]` : JSON.stringify(value)}`,
34
+ )
35
+ .join(',')}}`,
36
+ )
37
+ return `{"catalog":${JSON.stringify(data.catalog)},"rootContext":[\n${data.rootContext.map((row) => JSON.stringify(row)).join(',\n')}\n],"candidates":[\n${candidates.join(',\n')}\n]}`
38
+ }
39
+
40
+ async function latestJudgments(manifest) {
41
+ const latest = new Map()
42
+ const pendingProjectTasks = []
43
+ for (const item of manifest.tasks) {
44
+ if (item.projectKeys) {
45
+ if (!(await optional(item.answer))) {
46
+ pendingProjectTasks.push(item)
47
+ continue
48
+ }
49
+ const results = await answers([item], 'items')
50
+ const ids = results.map((result) => result.projectId)
51
+ if (ids.length !== item.projectKeys.length || new Set(ids).size !== ids.length)
52
+ throw new Error('PROJECT_METADATA_COVERAGE_INVALID')
53
+ const expected = item.projectKeys.map(
54
+ (key) => manifest.classification.projects.find((project) => project.key === key)?.projectId,
55
+ )
56
+ if (expected.some((id) => !id) || expected.some((id) => !ids.includes(id)))
57
+ throw new Error('PROJECT_METADATA_COVERAGE_INVALID')
58
+ for (const result of results) {
59
+ if (
60
+ !item.projectKeys.includes(
61
+ manifest.classification.projects.find(
62
+ (project) => project.projectId === result.projectId,
63
+ )?.key,
64
+ )
65
+ )
66
+ throw new Error('PROJECT_METADATA_COVERAGE_INVALID')
67
+ validateClassificationProject(result, result.projectId)
68
+ }
69
+ continue
70
+ }
71
+ if (!(await optional(item.answer))) continue
72
+ const results = await answers([item], 'judgments')
73
+ const value = JSON.parse(await readFile(item.answer, 'utf8'))
74
+ const requested = value.evidenceRootKeys ?? []
75
+ if (!Array.isArray(requested) || requested.some((key) => !item.rootKeys.includes(key)))
76
+ throw new Error('INVALID_EVIDENCE_ROOTS')
77
+ if (item.evidenceRound > 0 && requested.length) throw new Error('EVIDENCE_ESCALATION_LIMIT')
78
+ const seen = new Set()
79
+ for (const result of results) {
80
+ if (!item.rootKeys.includes(result.rootKey) || seen.has(result.rootKey))
81
+ throw new Error('CLASSIFICATION_JUDGMENT_COVERAGE_INVALID')
82
+ validateClassificationJudgment(result, result.rootKey)
83
+ if (
84
+ result.targetProjectId &&
85
+ !manifest.classification.projects.some(
86
+ (project) => project.projectId === result.targetProjectId,
87
+ )
88
+ )
89
+ throw new Error('UNKNOWN_TARGET_PROJECT')
90
+ seen.add(result.rootKey)
91
+ latest.set(result.rootKey, result)
92
+ }
93
+ if (seen.size !== item.rootKeys.length)
94
+ throw new Error('CLASSIFICATION_JUDGMENT_COVERAGE_INVALID')
95
+ }
96
+ return { latest, pendingProjectTasks }
97
+ }
98
+
99
+ export async function consolidateClassification(directory, manifest, { refresh = false } = {}) {
100
+ if (!manifest.classification?.complete) throw new Error('CLASSIFICATION_READS_INCOMPLETE')
101
+ const { latest, pendingProjectTasks } = await latestJudgments(manifest)
102
+ const unknownKeys = manifest.classification.unknown.map((item) => item.rootKey)
103
+ if (unknownKeys.some((key) => !latest.has(key)))
104
+ throw new Error('CLASSIFICATION_ANSWERS_INCOMPLETE')
105
+ if (refresh && (!manifest.consolidation || manifest.consolidation.automatic))
106
+ throw new Error('CLASSIFICATION_REFRESH_UNAVAILABLE')
107
+ if (unknownKeys.length === 0) {
108
+ manifest.consolidation = {
109
+ automatic: true,
110
+ answer: null,
111
+ candidates: [],
112
+ projectCatalog: manifest.classification.projects.map((project) => ({
113
+ projectId: project.projectId,
114
+ projectName: project.projectName,
115
+ })),
116
+ }
117
+ await save(join(directory, 'manifest.json'), manifest)
118
+ return {
119
+ stage: 'classification',
120
+ consolidationReady: true,
121
+ readyForIngest: pendingProjectTasks.length === 0,
122
+ automatic: true,
123
+ tasks: pendingProjectTasks,
124
+ }
125
+ }
126
+ if (manifest.consolidation) {
127
+ if (refresh && manifest.consolidation.automatic)
128
+ throw new Error('CLASSIFICATION_REFRESH_UNAVAILABLE')
129
+ if (refresh) {
130
+ manifest.consolidationHistory ??= []
131
+ manifest.consolidationHistory.push({
132
+ ...manifest.consolidation,
133
+ supersededAt: new Date().toISOString(),
134
+ })
135
+ } else {
136
+ if (manifest.consolidation.automatic)
137
+ return {
138
+ stage: 'classification',
139
+ consolidationReady: true,
140
+ readyForIngest: pendingProjectTasks.length === 0,
141
+ automatic: true,
142
+ tasks: pendingProjectTasks,
143
+ }
144
+ if (!(await optional(manifest.consolidation.answer)))
145
+ return {
146
+ stage: 'classification',
147
+ consolidationReady: true,
148
+ readyForIngest: false,
149
+ tasks: [dispatchDescriptor(manifest.consolidation)],
150
+ }
151
+ const result = await createMetadataTasks(directory, manifest)
152
+ const tasks = [...pendingProjectTasks, ...result.tasks]
153
+ return {
154
+ ...result,
155
+ readyForIngest: result.readyForIngest && pendingProjectTasks.length === 0,
156
+ tasks,
157
+ }
158
+ }
159
+ }
160
+ const roots = unknownKeys.map((rootKey) => {
161
+ const judgment = latest.get(rootKey)
162
+ validateClassificationJudgment(judgment, rootKey)
163
+ return {
164
+ rootKey,
165
+ projectName: judgment.projectName,
166
+ workIntent: judgment.workIntent,
167
+ groupingBasis: judgment.groupingBasis,
168
+ ...(judgment.targetProjectId ? { targetProjectId: judgment.targetProjectId } : {}),
169
+ }
170
+ })
171
+ const candidatesByName = new Map()
172
+ for (const root of roots) {
173
+ const projectName = root.projectName.trim().normalize('NFC')
174
+ const name = projectName.toLocaleLowerCase('en-US')
175
+ const identity = JSON.stringify([root.targetProjectId ?? null, name])
176
+ let candidate = candidatesByName.get(identity)
177
+ if (!candidate) {
178
+ candidate = {
179
+ key: `c${candidatesByName.size + 1}`,
180
+ projectName,
181
+ ...(root.targetProjectId ? { targetProjectId: root.targetProjectId } : {}),
182
+ roots: [],
183
+ }
184
+ candidatesByName.set(identity, candidate)
185
+ }
186
+ candidate.roots.push([
187
+ root.rootKey,
188
+ root.workIntent,
189
+ root.groupingBasis,
190
+ ...(root.targetProjectId ? [root.targetProjectId] : []),
191
+ ])
192
+ }
193
+ const catalog = manifest.classification.projects.map((project) => ({
194
+ projectId: project.projectId,
195
+ projectName: project.projectName,
196
+ }))
197
+ const candidates = [...candidatesByName.values()]
198
+ const rootContext = manifest.classification.unknown.map((root) => [
199
+ root.rootKey,
200
+ root.metadata?.workspaceKind ?? null,
201
+ root.metadata?.workspaceName ?? null,
202
+ root.metadata?.projectEvidence ?? null,
203
+ ])
204
+ const data = { catalog, rootContext, candidates }
205
+ if (taskTokens(directory, 'consolidation', instruction, data, formatPayload) > budget)
206
+ throw new Error('CLASSIFICATION_CONSOLIDATION_TOO_LARGE')
207
+ const consolidationId = refresh ? `consolidation-${randomUUID()}` : 'consolidation'
208
+ manifest.consolidation = await task(directory, consolidationId, instruction, data, formatPayload)
209
+ const descriptor = JSON.parse(await readFile(manifest.consolidation.descriptor, 'utf8'))
210
+ descriptor.candidates = candidates
211
+ descriptor.projectCatalog = catalog
212
+ await save(manifest.consolidation.descriptor, descriptor)
213
+ manifest.consolidation.candidates = candidates
214
+ manifest.consolidation.projectCatalog = catalog
215
+ await save(join(directory, 'manifest.json'), manifest)
216
+ return {
217
+ stage: 'classification',
218
+ consolidationReady: true,
219
+ readyForIngest: false,
220
+ tasks: [dispatchDescriptor(manifest.consolidation)],
221
+ }
222
+ }
223
+
224
+ export { classificationSubmission } from './journey-consolidation-submit.mjs'
@@ -0,0 +1,143 @@
1
+ import { join } from 'node:path'
2
+ import { hash, optional, save } from './journey-state.mjs'
3
+
4
+ const selected = (view) =>
5
+ view.works.filter((work) => work.access !== 'drop' && work.rootSessionIds.length)
6
+
7
+ function layout(view) {
8
+ return view.works
9
+ .map((work) => ({
10
+ id: work.id,
11
+ inputRevision: work.inputRevision,
12
+ access: work.access,
13
+ roots: [...work.rootSessionIds].sort((a, b) => a.localeCompare(b)),
14
+ }))
15
+ .sort((a, b) => a.id.localeCompare(b.id))
16
+ }
17
+
18
+ function binding(view, journeyId) {
19
+ const roots = view.works
20
+ .flatMap((work) => work.rootSessionIds.map((id) => [id, work.access]))
21
+ .sort((a, b) => a[0].localeCompare(b[0]))
22
+ if (new Set(roots.map(([id]) => id)).size !== roots.length)
23
+ throw new Error('SCAN_TIMING_DUPLICATE_ROOT')
24
+ return hash({
25
+ journeyId,
26
+ snapshotId: view.snapshotId,
27
+ classificationRevision: view.classificationRevision,
28
+ configurationId: view.configurationId,
29
+ roots,
30
+ })
31
+ }
32
+
33
+ function complete(view) {
34
+ if (
35
+ !view.consent ||
36
+ view.scope.status !== 'confirmed' ||
37
+ !view.readingComplete ||
38
+ view.phase === 'paused'
39
+ )
40
+ return false
41
+ if (
42
+ selected(view).some(
43
+ (work) => ['pending', 'blocked'].includes(work.status) || !work.classificationMetadata,
44
+ )
45
+ )
46
+ return false
47
+ const pending = view.privacy.terms
48
+ .filter((term) => term.disposition === 'pending')
49
+ .map((term) => JSON.stringify([term.type, term.value.normalize('NFC')]))
50
+ if (!pending.length) return true
51
+ const cards = view.privacy.cards
52
+ if (!cards.length || cards.length > 20) return false
53
+ const covered = cards.flatMap((card) => card.termKeys)
54
+ return (
55
+ covered.length === pending.length &&
56
+ new Set(covered).size === covered.length &&
57
+ pending.every((key) => covered.includes(key))
58
+ )
59
+ }
60
+
61
+ /** Observe the complete static + AI + metadata + cards wall interval without owning preparation. */
62
+ export async function scanTiming(run, journeyId, view, start = false, call) {
63
+ const path = join(run, 'scan-timing.json')
64
+ let receipt = await optional(path)
65
+ const identity = binding(view, journeyId)
66
+ if (!receipt && !start) return null
67
+ const guidance = !receipt?.completedAt
68
+ ? (await call('review.guidance', { journeyId })).guidance
69
+ : null
70
+ const authority = guidance
71
+ ? hash({
72
+ instructions: guidance.instructions,
73
+ valueOverrides: guidance.valueOverrides,
74
+ rules: guidance.rules,
75
+ conditions: guidance.conditions.filter((condition) =>
76
+ ['explicit', 'delegate', 'preference'].includes(condition.origin),
77
+ ),
78
+ })
79
+ : null
80
+ if (!receipt) {
81
+ if (
82
+ !view.consent ||
83
+ !view.snapshotId ||
84
+ view.classificationRevision === null ||
85
+ view.scope.status !== 'confirmed' ||
86
+ view.phase === 'paused' ||
87
+ !selected(view).length
88
+ )
89
+ throw new Error('SCAN_TIMING_NOT_ALLOWED')
90
+ if (
91
+ view.readingComplete ||
92
+ view.privacy.terms.length ||
93
+ view.privacy.cards.length ||
94
+ view.works.some(
95
+ (work) =>
96
+ work.status !== 'pending' ||
97
+ work.totalBatches ||
98
+ work.completedBatches ||
99
+ work.emptyReview ||
100
+ work.classificationMetadata ||
101
+ work.metadataReviewedDigest ||
102
+ work.artifact,
103
+ )
104
+ )
105
+ throw new Error('SCAN_TIMING_ALREADY_PREPARED')
106
+ receipt = {
107
+ binding: identity,
108
+ authority,
109
+ initialGuidanceDigest: view.guidanceDigest,
110
+ startedAt: new Date().toISOString(),
111
+ completedAt: null,
112
+ startRevision: view.revision,
113
+ snapshotId: view.snapshotId,
114
+ initialWorks: layout(view),
115
+ }
116
+ await save(path, receipt)
117
+ }
118
+ if (receipt.binding !== identity || (authority !== null && receipt.authority !== authority))
119
+ throw new Error('SCAN_TIMING_SCOPE_CHANGED')
120
+ if (!receipt.completedAt && complete(view)) {
121
+ receipt = {
122
+ ...receipt,
123
+ completedAt: new Date().toISOString(),
124
+ endRevision: view.revision,
125
+ finalWorks: layout(view),
126
+ finalGuidanceDigest: view.guidanceDigest,
127
+ }
128
+ await save(path, receipt)
129
+ }
130
+ const elapsedMs =
131
+ Date.parse(receipt.completedAt ?? new Date().toISOString()) - Date.parse(receipt.startedAt)
132
+ const { initialWorks, finalWorks, ...summary } = receipt
133
+ return {
134
+ ...summary,
135
+ initialWorkCount: initialWorks.length,
136
+ ...(finalWorks ? { finalWorkCount: finalWorks.length } : {}),
137
+ receipt: path,
138
+ status: receipt.completedAt ? 'complete' : 'running',
139
+ elapsedMs,
140
+ budgetMs: 30 * 60 * 1000,
141
+ withinBudget: receipt.completedAt ? elapsedMs < 30 * 60 * 1000 : null,
142
+ }
143
+ }
@@ -0,0 +1,42 @@
1
+ export function save(path: string, value: unknown): Promise<void>
2
+ export function hash(value: unknown): string
3
+ export function definiteWriteRejection(error: unknown): boolean
4
+ export function stageFingerprint(view: unknown, stage: string): string
5
+ export function planResolutions(
6
+ view: {
7
+ works: {
8
+ access: string
9
+ rootSessionIds: string[]
10
+ projectName?: string
11
+ classificationMetadata?: { projectName?: string }
12
+ }[]
13
+ privacy: {
14
+ terms: { type: string; value: string; disposition: string }[]
15
+ cards?: { termKeys: string[]; answer?: { optionId: string } | null }[]
16
+ }
17
+ valueOverrides?: { value: string; choice: 'hide' | 'keep' }[]
18
+ },
19
+ resolutions: {
20
+ type: string
21
+ value: string
22
+ disposition: 'hidden' | 'keep'
23
+ reason: string
24
+ }[],
25
+ ): {
26
+ id: string
27
+ resolutions: { type: string; value: string; disposition: 'hidden' | 'keep'; reason: string }[]
28
+ }[]
29
+ export function recoverWrites(
30
+ directory: string,
31
+ manifest: { stage: string; tasks?: { id: string }[]; resolutionBatches?: { id: string }[] },
32
+ call: (
33
+ procedure: string,
34
+ request: unknown,
35
+ ) => Promise<{ status: string; state?: { revision: number } }>,
36
+ ): Promise<{
37
+ recovered: number
38
+ completed: number
39
+ total: number
40
+ stale: boolean
41
+ rejected: string | null
42
+ }>
@@ -0,0 +1,268 @@
1
+ import { randomUUID, createHash } from 'node:crypto'
2
+ import { readFile, writeFile, rename } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ export const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
6
+ export const read = async (path) => JSON.parse(await readFile(path, 'utf8'))
7
+ export async function optional(path) {
8
+ try {
9
+ return await read(path)
10
+ } catch (error) {
11
+ if (error.code === 'ENOENT') return null
12
+ throw error
13
+ }
14
+ }
15
+ export async function save(path, value) {
16
+ const temporary = `${path}.${randomUUID()}.tmp`
17
+ await writeFile(temporary, typeof value === 'string' ? value : JSON.stringify(value), {
18
+ mode: 0o600,
19
+ flag: 'wx',
20
+ })
21
+ await rename(temporary, path)
22
+ }
23
+ /** A journey-wide dictionary remains relevant while any Work is selected. */
24
+ export function activeTerms(view) {
25
+ return view.works.some((work) => work.access !== 'drop' && work.rootSessionIds.length)
26
+ ? view.privacy.terms
27
+ : []
28
+ }
29
+ export function phaseOf(view) {
30
+ if (!view.consent) return 'consent'
31
+ if (view.phase === 'paused') return 'paused'
32
+ if (view.phase === 'completed') return 'completed'
33
+ if (view.phase === 'sending') return 'sending'
34
+ if (!view.snapshotId) return 'freeze'
35
+ if (view.classificationRevision === null) return 'classification'
36
+ if (view.scope.status !== 'confirmed') return 'classification-confirmation'
37
+ if (!view.readingComplete) return 'scan'
38
+ const pending = activeTerms(view).some((term) => term.disposition === 'pending')
39
+ if (pending && !view.privacy.cards.length) return 'cards'
40
+ if (view.privacy.cards.length) {
41
+ if (view.privacy.cards.some((card) => !card.answer)) return 'browser-answers'
42
+ if (view.privacy.confirmedAnswersRevision !== view.privacy.answersRevision)
43
+ return 'answers-confirmation'
44
+ }
45
+ if (pending) return 'resolution'
46
+ const selected = view.works.filter((work) => work.access !== 'drop' && work.rootSessionIds.length)
47
+ // Track B builds each Work's private packaging base in the background (ADR 0171).
48
+ 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'
51
+ return 'send'
52
+ }
53
+ export function stageFingerprint(view, stage) {
54
+ return hash({
55
+ snapshotId: view.snapshotId,
56
+ guidance: view.guidanceDigest,
57
+ works: view.works.map((work) => [
58
+ work.id,
59
+ work.inputRevision,
60
+ work.rootSessionIds,
61
+ work.access,
62
+ stage === 'classification'
63
+ ? [
64
+ work.title,
65
+ work.projectName,
66
+ work.description,
67
+ work.projectType,
68
+ work.classificationRecommendation,
69
+ ]
70
+ : work.classificationMetadata?.digest,
71
+ ]),
72
+ pending: ['cards', 'resolution'].includes(stage)
73
+ ? activeTerms(view).filter((term) => term.disposition === 'pending')
74
+ : null,
75
+ answers: ['cards', 'resolution'].includes(stage)
76
+ ? [view.privacy.answersRevision, view.privacy.confirmedAnswersRevision, view.privacy.cards]
77
+ : null,
78
+ })
79
+ }
80
+ export const procedureFor = (stage) =>
81
+ ({
82
+ classification: 'journey.classificationSubmit',
83
+ cards: 'journey.cardsSubmit',
84
+ resolution: 'journey.resolve',
85
+ })[stage]
86
+
87
+ /** These authoritative failures cannot have committed a phase write. */
88
+ export const definiteWriteRejection = (error) =>
89
+ [
90
+ 'INVALID_SELECTION',
91
+ 'INVALID_ARGUMENTS',
92
+ 'INVALID_REVIEW_SPAN',
93
+ 'UNKNOWN_JOURNEY',
94
+ 'UNKNOWN_WORKSPACE',
95
+ ].includes(error?.code)
96
+
97
+ /** Unknown outcomes are replayed byte-for-byte before any freshness decision or refresh. */
98
+ export async function recoverWrites(directory, manifest, call) {
99
+ const names = manifest.resolutionBatches
100
+ ? manifest.resolutionBatches.map((batch) => batch.id)
101
+ : ['batch']
102
+ let recovered = 0,
103
+ completed = 0,
104
+ stale = false,
105
+ rejected = null
106
+ for (const name of names) {
107
+ const prefix = name === 'batch' ? '' : `${name}.`
108
+ const request = await optional(join(directory, `${prefix}request.json`))
109
+ if (!request) continue
110
+ let receipt = await optional(join(directory, `${prefix}receipt.json`))
111
+ if (!receipt) {
112
+ try {
113
+ const result = await call(procedureFor(manifest.stage), request)
114
+ receipt = { status: result.status, revision: result.state?.revision }
115
+ await save(join(directory, `${prefix}receipt.json`), receipt)
116
+ recovered++
117
+ } catch (error) {
118
+ const code = error?.code
119
+ if (!definiteWriteRejection(error)) throw error
120
+ receipt = { status: 'rejected', code }
121
+ await save(join(directory, `${prefix}receipt.json`), receipt)
122
+ }
123
+ }
124
+ if (receipt.status === 'stale') stale = true
125
+ else if (receipt.status === 'rejected') rejected = receipt.code
126
+ else completed++
127
+ }
128
+ return { recovered, completed, total: names.length, stale, rejected }
129
+ }
130
+
131
+ export const resolutionAuthority = (view) =>
132
+ hash({
133
+ tasks: stageFingerprint({ ...view, privacy: { ...view.privacy, terms: [] } }, 'resolution'),
134
+ scope: view.scope,
135
+ classificationRevision: view.classificationRevision,
136
+ })
137
+ export const resolutionEvidence = (view) =>
138
+ Object.fromEntries(
139
+ activeTerms(view)
140
+ .filter((term) => term.disposition === 'pending')
141
+ .map((term) => [termKey(term), hash(term)]),
142
+ )
143
+ const termKey = (term) => JSON.stringify([term.type, term.value.normalize('NFC')])
144
+
145
+ /** Plan complete coverage before any write; same-literal type decisions stay atomic. */
146
+ const OWN_PROJECT_KEEP_REASON =
147
+ "A Keep card answer for one of the person's own Work project names takes precedence over Hide."
148
+ export function planResolutions(view, resolutions) {
149
+ const pending = new Set(
150
+ activeTerms(view)
151
+ .filter((term) => term.disposition === 'pending')
152
+ .map(termKey),
153
+ )
154
+ const seen = new Set()
155
+ const groups = new Map()
156
+ for (const term of resolutions) {
157
+ const key = termKey(term)
158
+ if (!pending.has(key) || seen.has(key) || !['hidden', 'keep'].includes(term.disposition))
159
+ throw new Error('INVALID_SELECTION')
160
+ seen.add(key)
161
+ const value = term.value.normalize('NFC')
162
+ const group = groups.get(value) ?? []
163
+ group.push(term)
164
+ groups.set(value, group)
165
+ }
166
+ if (seen.size !== pending.size) throw new Error('INVALID_SELECTION')
167
+ const termsByValue = Map.groupBy(activeTerms(view), (term) => term.value.normalize('NFC'))
168
+ const overridesByValue = new Map()
169
+ for (const item of view.valueOverrides ?? []) {
170
+ const value = item.value.normalize('NFC')
171
+ if (!overridesByValue.has(value)) overridesByValue.set(value, item)
172
+ }
173
+ // Hide wins for a value covered by cards with different answers (ADR 0191), except that a Keep
174
+ // card answer keeps one of the person's own Work project or repository names (ADR 0202).
175
+ const cardValues = (optionId) => {
176
+ const keys = new Set(
177
+ (view.privacy.cards ?? []).flatMap((card) =>
178
+ card.answer?.optionId === optionId ? card.termKeys : [],
179
+ ),
180
+ )
181
+ return new Set(
182
+ activeTerms(view)
183
+ .filter((term) => keys.has(termKey(term)))
184
+ .map((term) => term.value.normalize('NFC')),
185
+ )
186
+ }
187
+ const hideCardValues = cardValues('hide')
188
+ const keepCardValues = cardValues('keep')
189
+ const ownNames = new Set(
190
+ view.works.flatMap((work) =>
191
+ [work.projectName, work.classificationMetadata?.projectName]
192
+ .filter((name) => typeof name === 'string' && name)
193
+ .map((name) => name.normalize('NFC').toLowerCase()),
194
+ ),
195
+ )
196
+ const batches = []
197
+ let batch = []
198
+ for (const group of groups.values()) {
199
+ if (group.length > 20000) throw new Error('PROCESSING_LIMIT')
200
+ if (batch.length + group.length > 20000) {
201
+ batches.push({ id: randomUUID(), resolutions: batch })
202
+ batch = []
203
+ }
204
+ const value = group[0]?.value.normalize('NFC') ?? ''
205
+ const sameValue = termsByValue.get(value) ?? []
206
+ const mandatory = sameValue.some((term) => ['contact', 'credentials'].includes(term.type))
207
+ const override = overridesByValue.get(value)
208
+ const ownKeep =
209
+ !mandatory && !override && keepCardValues.has(value) && ownNames.has(value.toLowerCase())
210
+ const hideWins =
211
+ mandatory ||
212
+ (override ? override.choice === 'hide' : false) ||
213
+ (!override &&
214
+ !ownKeep &&
215
+ (hideCardValues.has(value) ||
216
+ group.some((term) => term.disposition === 'hidden') ||
217
+ sameValue.some((term) => term.disposition === 'hidden')))
218
+ batch.push(
219
+ ...group.map((term) =>
220
+ hideWins
221
+ ? {
222
+ ...term,
223
+ disposition: 'hidden',
224
+ reason:
225
+ term.disposition === 'hidden'
226
+ ? term.reason
227
+ : mandatory
228
+ ? 'Mandatory masking always hides this keyword.'
229
+ : override?.choice === 'hide'
230
+ ? 'An explicit current value rule hides this keyword.'
231
+ : hideCardValues.has(value) &&
232
+ !group.some((item) => item.disposition === 'hidden') &&
233
+ !sameValue.some((item) => item.disposition === 'hidden')
234
+ ? 'A Hide card answer covering this value takes precedence over Keep.'
235
+ : 'A Hidden judgment for this NFC keyword takes precedence across term types.',
236
+ }
237
+ : override?.choice === 'keep'
238
+ ? { ...term, disposition: 'keep' }
239
+ : ownKeep && term.disposition !== 'keep'
240
+ ? { ...term, disposition: 'keep', reason: OWN_PROJECT_KEEP_REASON }
241
+ : term,
242
+ ),
243
+ )
244
+ }
245
+ if (batch.length) batches.push({ id: randomUUID(), resolutions: batch })
246
+ return batches
247
+ }
248
+
249
+ /** Receipts explain our own pending-set changes without accepting other edits as fresh. */
250
+ export async function assertResolutionFresh(directory, manifest, view) {
251
+ if (resolutionAuthority(view) !== manifest.resolutionAuthority) throw new Error('STALE_TASKS')
252
+ const expected = new Set()
253
+ for (const batch of manifest.resolutionBatches) {
254
+ const receipt = await optional(join(directory, `${batch.id}.receipt.json`))
255
+ if (receipt && receipt.status !== 'stale') continue
256
+ for (const term of batch.resolutions) expected.add(termKey(term))
257
+ }
258
+ const evidence = resolutionEvidence(view)
259
+ if ([...expected].some((key) => evidence[key] !== manifest.resolutionEvidence[key]))
260
+ throw new Error('STALE_TASKS')
261
+ const actual = new Set(
262
+ activeTerms(view)
263
+ .filter((term) => term.disposition === 'pending')
264
+ .map(termKey),
265
+ )
266
+ if (expected.size !== actual.size || [...expected].some((key) => !actual.has(key)))
267
+ throw new Error('STALE_TASKS')
268
+ }