@gitroll/tacitt-cli 0.0.0-stage → 0.2.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 (142) 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-B8DAiQ6Z.mjs +71 -0
  5. package/dist/agent-setup-worker.mjs +56 -0
  6. package/dist/cli.mjs +1442 -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-waS5FnN5.mjs +84 -0
  11. package/dist/heap-1FYhGds4.mjs +75 -0
  12. package/dist/heap-BHcvxWCw.mjs +7265 -0
  13. package/dist/inspection-worker.mjs +57 -0
  14. package/dist/job-J3NFw3MH.mjs +194 -0
  15. package/dist/locate-U3-nkird.mjs +10670 -0
  16. package/dist/messages-D3m3bhva.mjs +40 -0
  17. package/dist/model-BJdTLNjT.mjs +41 -0
  18. package/dist/operation-executor.mjs +181 -0
  19. package/dist/preview-worker.mjs +22 -0
  20. package/dist/privacy-validator.mjs +46 -0
  21. package/dist/query-BA0XCwnR.mjs +14869 -0
  22. package/dist/run-BeLHsZWb.mjs +916 -0
  23. package/dist/session-process.mjs +90 -0
  24. package/dist/src-B5SArpVY.mjs +6601 -0
  25. package/dist/src-CC95LYYp.mjs +12985 -0
  26. package/dist/src-k0Ya1Bri.mjs +16422 -0
  27. package/dist/store-database-worker.mjs +14 -0
  28. package/dist/web/THIRD_PARTY_LICENSES.md +388 -0
  29. package/dist/web/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  30. package/dist/web/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  31. package/dist/web/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  32. package/dist/web/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  33. package/dist/web/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  34. package/dist/web/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  35. package/dist/web/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  36. package/dist/web/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  37. package/dist/web/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  38. package/dist/web/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  39. package/dist/web/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  40. package/dist/web/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  41. package/dist/web/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  42. package/dist/web/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  43. package/dist/web/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  44. package/dist/web/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  45. package/dist/web/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  46. package/dist/web/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  47. package/dist/web/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  48. package/dist/web/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  49. package/dist/web/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
  50. package/dist/web/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
  51. package/dist/web/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff +0 -0
  52. package/dist/web/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 +0 -0
  53. package/dist/web/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
  54. package/dist/web/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
  55. package/dist/web/assets/ibm-plex-sans-cyrillic-700-normal-D_uavGvn.woff2 +0 -0
  56. package/dist/web/assets/ibm-plex-sans-cyrillic-700-normal-RFb1v-CK.woff +0 -0
  57. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
  58. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
  59. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 +0 -0
  60. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff +0 -0
  61. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
  62. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
  63. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-700-normal-BmRLXWpJ.woff2 +0 -0
  64. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-700-normal-DCQwxkRk.woff +0 -0
  65. package/dist/web/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
  66. package/dist/web/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
  67. package/dist/web/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff +0 -0
  68. package/dist/web/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 +0 -0
  69. package/dist/web/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
  70. package/dist/web/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
  71. package/dist/web/assets/ibm-plex-sans-greek-700-normal-CkZN0sRy.woff +0 -0
  72. package/dist/web/assets/ibm-plex-sans-greek-700-normal-wyJ6jzdA.woff2 +0 -0
  73. package/dist/web/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  74. package/dist/web/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  75. package/dist/web/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  76. package/dist/web/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  77. package/dist/web/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  78. package/dist/web/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  79. package/dist/web/assets/ibm-plex-sans-latin-700-normal-Bth3BMcD.woff +0 -0
  80. package/dist/web/assets/ibm-plex-sans-latin-700-normal-Bxkt5Cjx.woff2 +0 -0
  81. package/dist/web/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
  82. package/dist/web/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
  83. package/dist/web/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff +0 -0
  84. package/dist/web/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
  85. package/dist/web/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
  86. package/dist/web/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
  87. package/dist/web/assets/ibm-plex-sans-latin-ext-700-normal-DZH6Xh-4.woff +0 -0
  88. package/dist/web/assets/ibm-plex-sans-latin-ext-700-normal-aMgXseD4.woff2 +0 -0
  89. package/dist/web/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
  90. package/dist/web/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
  91. package/dist/web/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff +0 -0
  92. package/dist/web/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 +0 -0
  93. package/dist/web/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
  94. package/dist/web/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
  95. package/dist/web/assets/ibm-plex-sans-vietnamese-700-normal-BV5B8Are.woff +0 -0
  96. package/dist/web/assets/ibm-plex-sans-vietnamese-700-normal-CkEo6LDy.woff2 +0 -0
  97. package/dist/web/assets/index-Bjos73wK.js +67 -0
  98. package/dist/web/assets/index-Bl4C2b2_.css +1 -0
  99. package/dist/web/index.html +14 -0
  100. package/package.json +56 -3
  101. package/skills/INDEX.md +9 -0
  102. package/skills/tacitt/INDEX.md +11 -0
  103. package/skills/tacitt/SKILL.md +44 -0
  104. package/skills/tacitt/references/INDEX.md +15 -0
  105. package/skills/tacitt/references/b-method.md +22 -0
  106. package/skills/tacitt/references/bootstrap.md +109 -0
  107. package/skills/tacitt/references/classification.md +83 -0
  108. package/skills/tacitt/references/commands.md +147 -0
  109. package/skills/tacitt/references/journey.md +54 -0
  110. package/skills/tacitt/references/parallel-review.md +64 -0
  111. package/skills/tacitt/references/send.md +30 -0
  112. package/skills/tacitt/scripts/INDEX.md +43 -0
  113. package/skills/tacitt/scripts/answer.mjs +278 -0
  114. package/skills/tacitt/scripts/describe.mjs +55 -0
  115. package/skills/tacitt/scripts/journey-card-consolidation.d.mts +17 -0
  116. package/skills/tacitt/scripts/journey-card-consolidation.mjs +209 -0
  117. package/skills/tacitt/scripts/journey-classification-metadata.d.mts +16 -0
  118. package/skills/tacitt/scripts/journey-classification-metadata.mjs +111 -0
  119. package/skills/tacitt/scripts/journey-classification-progress.mjs +195 -0
  120. package/skills/tacitt/scripts/journey-classification-tasks.d.mts +18 -0
  121. package/skills/tacitt/scripts/journey-classification-tasks.mjs +345 -0
  122. package/skills/tacitt/scripts/journey-classification-validation.d.mts +20 -0
  123. package/skills/tacitt/scripts/journey-classification-validation.mjs +240 -0
  124. package/skills/tacitt/scripts/journey-classification.mjs +145 -0
  125. package/skills/tacitt/scripts/journey-consolidation-submit.mjs +227 -0
  126. package/skills/tacitt/scripts/journey-consolidation.d.mts +14 -0
  127. package/skills/tacitt/scripts/journey-consolidation.mjs +227 -0
  128. package/skills/tacitt/scripts/journey-scan-timing.mjs +143 -0
  129. package/skills/tacitt/scripts/journey-state.d.mts +43 -0
  130. package/skills/tacitt/scripts/journey-state.mjs +294 -0
  131. package/skills/tacitt/scripts/journey-tasks.d.mts +26 -0
  132. package/skills/tacitt/scripts/journey-tasks.mjs +185 -0
  133. package/skills/tacitt/scripts/journey.d.mts +40 -0
  134. package/skills/tacitt/scripts/journey.mjs +592 -0
  135. package/skills/tacitt/scripts/local-cli.d.mts +18 -0
  136. package/skills/tacitt/scripts/local-cli.mjs +116 -0
  137. package/skills/tacitt/scripts/privacy-validator.mjs +10369 -0
  138. package/skills/tacitt/scripts/review-stream-client.d.mts +22 -0
  139. package/skills/tacitt/scripts/review-stream-client.mjs +194 -0
  140. package/skills/tacitt/scripts/review.d.mts +87 -0
  141. package/skills/tacitt/scripts/review.mjs +1610 -0
  142. package/skills/tacitt/scripts/verify-cli.mjs +10 -0
@@ -0,0 +1,111 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { join } from 'node:path'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { optional, save } from './journey-state.mjs'
5
+ import { task, taskTokens } from './journey-tasks.mjs'
6
+ import {
7
+ validateConsolidationAnswer,
8
+ compileAssignments,
9
+ } from './journey-classification-validation.mjs'
10
+
11
+ const metadataBudget = 12000
12
+
13
+ export async function createMetadataTasks(directory, manifest) {
14
+ const consolidation = manifest.consolidation
15
+ const answerText = await readFile(consolidation.answer, 'utf8')
16
+ const digest = createHash('sha256').update(answerText).digest('hex')
17
+ if (consolidation.answerHash && consolidation.answerHash !== digest)
18
+ consolidation.metadataTasks = []
19
+ consolidation.answerHash = digest
20
+ const answer = JSON.parse(answerText)
21
+ const checked = validateConsolidationAnswer(answer, consolidation)
22
+ if (checked.details.length) throw new Error('CLASSIFICATION_CONSOLIDATION_INVALID')
23
+ const assignment = compileAssignments(
24
+ consolidation.candidates,
25
+ answer.assignments,
26
+ answer.overrides ?? [],
27
+ )
28
+ const counts = new Map()
29
+ for (const id of assignment.values()) counts.set(id, (counts.get(id) ?? 0) + 1)
30
+ const byGroup = new Map()
31
+ const unsortedRootKeys = []
32
+ for (const candidate of consolidation.candidates)
33
+ for (const row of candidate.roots) {
34
+ const id = assignment.get(row[0])
35
+ const group = answer.groups.find((entry) => entry.id === id)
36
+ if (!group) throw new Error('CLASSIFICATION_CONSOLIDATION_INVALID')
37
+ if (group.targetProjectId) continue
38
+ if (group.groupKind === 'unsorted' || (group.groupKind === 'topic' && counts.get(id) < 3))
39
+ unsortedRootKeys.push(row[0])
40
+ else byGroup.set(id, [...(byGroup.get(id) ?? []), row[0]])
41
+ }
42
+ const metadataTasks = consolidation.metadataTasks ?? []
43
+ const made = new Set(metadataTasks.flatMap((item) => item.projectIds ?? []))
44
+ const pendingProjects = []
45
+ for (const group of answer.groups) {
46
+ if (group.groupKind === 'unsorted' || group.targetProjectId) continue
47
+ const rootKeys = byGroup.get(group.id) ?? []
48
+ if (!rootKeys.length || made.has(group.id)) continue
49
+ const roots = rootKeys.map((rootKey) => {
50
+ const row = consolidation.candidates
51
+ .flatMap((candidate) => candidate.roots)
52
+ .find((entry) => entry[0] === rootKey)
53
+ if (!row) throw new Error('CLASSIFICATION_JUDGMENT_COVERAGE_INVALID')
54
+ return { rootKey, workIntent: row[1], groupingBasis: row[2] }
55
+ })
56
+ pendingProjects.push({ projectId: group.id, projectName: group.projectName, roots })
57
+ }
58
+ if (unsortedRootKeys.length && !made.has('unsorted')) {
59
+ const roots = unsortedRootKeys.map((rootKey) => {
60
+ const row = consolidation.candidates
61
+ .flatMap((candidate) => candidate.roots)
62
+ .find((entry) => entry[0] === rootKey)
63
+ if (!row) throw new Error('CLASSIFICATION_JUDGMENT_COVERAGE_INVALID')
64
+ return { rootKey, workIntent: row[1], groupingBasis: row[2] }
65
+ })
66
+ pendingProjects.push({ projectId: 'unsorted', projectName: 'Unsorted', roots })
67
+ }
68
+ const prompt =
69
+ 'Describe each retained new Work using concrete actions and objects from member workIntent. Return {items:[{projectId,description,projectType,recommendation:{access,reason}}]}; include every supplied projectId exactly once. Write one concise synthesized description (<=1000 chars), not copied titles or grouping boilerplate. Reasons (<=200 chars) explain the visibility recommendation using supplied grouping evidence. Membership and projectName are fixed. Recommend drop only when evidence shows the Work has no useful retained purpose; heterogeneous membership, uncertainty or incomplete evidence alone is not enough. Public means visible on the Passport after privacy masking; Private stays hidden but still counts. Recommend public for showcase work that demonstrates the person’s own skills, such as side projects, open-source or public repositories, coursework, hackathons, personal sites and tools, unless evidence ties it to employer or client confidentiality, internal systems or sensitive personal matters; recommend private for those. Missing privacy evidence alone is not a reason for private. Explain uncertainty in the reason. Types: work, side, coursework, personal, scratch, other; access: public, private, drop. This proposal is not approval.'
70
+ const batches = []
71
+ const taskOffset = metadataTasks.length
72
+ let batch = []
73
+ const batchFits = (projects) =>
74
+ taskTokens(directory, `metadata-${digest}-${taskOffset + batches.length + 1}`, prompt, {
75
+ projects,
76
+ }) <= metadataBudget
77
+ for (const project of pendingProjects) {
78
+ if (!batchFits([project])) throw new Error('CLASSIFICATION_METADATA_TOO_LARGE')
79
+ if (batch.length && !batchFits([...batch, project])) {
80
+ batches.push(batch)
81
+ batch = []
82
+ }
83
+ batch.push(project)
84
+ }
85
+ if (batch.length) batches.push(batch)
86
+ for (const [index, projects] of batches.entries()) {
87
+ const id = `metadata-${digest}-${taskOffset + index + 1}`
88
+ const item = await task(directory, id, prompt, { projects })
89
+ const descriptor = JSON.parse(await readFile(item.descriptor, 'utf8'))
90
+ descriptor.reconciliationHash = digest
91
+ await save(item.descriptor, descriptor)
92
+ const assigned = {
93
+ ...item,
94
+ projectIds: projects.map((project) => project.projectId),
95
+ reconciliationHash: digest,
96
+ }
97
+ metadataTasks.push(assigned)
98
+ consolidation.metadataTasks = metadataTasks
99
+ await save(join(directory, 'manifest.json'), manifest)
100
+ }
101
+ consolidation.metadataTasks = metadataTasks
102
+ await save(join(directory, 'manifest.json'), manifest)
103
+ const pending = []
104
+ for (const item of metadataTasks) if (!(await optional(item.answer))) pending.push(item)
105
+ return {
106
+ stage: 'classification',
107
+ consolidationReady: true,
108
+ readyForIngest: pending.length === 0,
109
+ tasks: pending,
110
+ }
111
+ }
@@ -0,0 +1,195 @@
1
+ import { hash, optional } from './journey-state.mjs'
2
+ import { answers } from './journey-tasks.mjs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import {
5
+ validateClassificationJudgment,
6
+ validateClassificationProject,
7
+ } from './journey-classification-validation.mjs'
8
+
9
+ function inputFingerprint(view, rootIds) {
10
+ return hash({
11
+ snapshotId: view.snapshotId,
12
+ roots: rootIds,
13
+ })
14
+ }
15
+
16
+ export async function classificationProgress(manifest) {
17
+ const classification = manifest.classification
18
+ if (!classification)
19
+ return {
20
+ generationComplete: false,
21
+ fixedProjects: 0,
22
+ remainingRoots: 0,
23
+ unresolvedRoots: 0,
24
+ generatedTasks: 0,
25
+ pendingAnswers: 0,
26
+ }
27
+ const pendingAnswers = (
28
+ await Promise.all(manifest.tasks.map((item) => optional(item.answer)))
29
+ ).filter((answer) => !answer).length
30
+ const assigned = new Set(manifest.tasks.flatMap((item) => item.rootKeys ?? []))
31
+ const assignedProjects = new Set(manifest.tasks.flatMap((item) => item.projectKeys ?? []))
32
+ return {
33
+ generationComplete: classification.complete,
34
+ routingMs: classification.routingMs ?? null,
35
+ fixedProjects: classification.projects.length,
36
+ unresolvedRoots: classification.unknown.length,
37
+ remainingRoots:
38
+ classification.unknown.filter((root) => !assigned.has(root.rootKey)).length +
39
+ classification.projects
40
+ .filter((project) => !assignedProjects.has(project.key))
41
+ .reduce((sum, project) => sum + project.rootKeys.length, 0),
42
+ generatedTasks: manifest.tasks.length,
43
+ pendingAnswers,
44
+ }
45
+ }
46
+
47
+ /** Publish counts derived from validated per-root task receipts and proposal coverage. */
48
+ export async function publishClassificationProgress(
49
+ manifest,
50
+ view,
51
+ call,
52
+ {
53
+ status = 'running',
54
+ stage = manifest.consolidation ? 'proposal' : 'classification',
55
+ proposalRevision = null,
56
+ } = {},
57
+ ) {
58
+ const classification = manifest.classification
59
+ // E2: once known Works are published, scans move the revision; Works show grouping instead.
60
+ if (!classification || manifest.partial) return
61
+ const rootIds = classification.roots
62
+ .map((root) => root.rootSessionId)
63
+ .toSorted((left, right) => left.localeCompare(right))
64
+ const knownKeys = new Set(classification.projects.flatMap((project) => project.rootKeys))
65
+ const aiCompleted = new Set()
66
+ const completed = new Set()
67
+ const latestJudgments = new Set()
68
+ for (const item of manifest.tasks) {
69
+ if (item.evidenceRound > 0 && !(await optional(item.answer))) continue
70
+ if (item.projectKeys) {
71
+ const assigned = item.projectKeys
72
+ const projects = await answers([item], 'items').catch((error) => {
73
+ if (error.code === 'ENOENT') return []
74
+ throw error
75
+ })
76
+ const ids = projects.map((project) => project.projectId)
77
+ const expectedIds = classification.projects
78
+ .filter((project) => assigned.includes(project.key))
79
+ .map((project) => project.projectId)
80
+ if (
81
+ ids.length === expectedIds.length &&
82
+ new Set(ids).size === ids.length &&
83
+ expectedIds.every((id) => ids.includes(id))
84
+ ) {
85
+ for (const project of projects) validateClassificationProject(project, project.projectId)
86
+ for (const project of classification.projects.filter((entry) =>
87
+ assigned.includes(entry.key),
88
+ )) {
89
+ for (const key of project.rootKeys) {
90
+ completed.add(key)
91
+ aiCompleted.add(key)
92
+ }
93
+ }
94
+ }
95
+ continue
96
+ }
97
+ if (!(await optional(item.answer))) continue
98
+ const judgments = await answers([item], 'judgments')
99
+ const answer = JSON.parse(await readFile(item.answer, 'utf8'))
100
+ const requestedEvidence = answer.evidenceRootKeys ?? []
101
+ if (
102
+ !Array.isArray(requestedEvidence) ||
103
+ requestedEvidence.some((key) => !item.rootKeys.includes(key))
104
+ )
105
+ throw new Error('INVALID_EVIDENCE_ROOTS')
106
+ if (item.evidenceRound > 0 && requestedEvidence.length)
107
+ throw new Error('EVIDENCE_ESCALATION_LIMIT')
108
+ const keys = judgments.map((judgment) => judgment.rootKey)
109
+ if (
110
+ keys.length !== item.rootKeys.length ||
111
+ new Set(keys).size !== keys.length ||
112
+ item.rootKeys.some((key) => !keys.includes(key))
113
+ )
114
+ continue
115
+ for (const judgment of judgments) validateClassificationJudgment(judgment, judgment.rootKey)
116
+ for (const key of keys) latestJudgments.add(key)
117
+ for (const key of requestedEvidence) latestJudgments.delete(key)
118
+ }
119
+ for (const key of latestJudgments) {
120
+ completed.add(key)
121
+ aiCompleted.add(key)
122
+ }
123
+ const grouped = new Set([...knownKeys].filter((key) => completed.has(key)))
124
+ const rootsByKey = new Map(classification.roots.map((root) => [root.key, root.rootSessionId]))
125
+ const completedRootIds = [...completed]
126
+ .map((key) => rootsByKey.get(key))
127
+ .filter(Boolean)
128
+ .toSorted((left, right) => left.localeCompare(right))
129
+ const groupedRootIds = [...grouped]
130
+ .map((key) => rootsByKey.get(key))
131
+ .filter(Boolean)
132
+ .toSorted((left, right) => left.localeCompare(right))
133
+ const aiCompletedRootIds = [...aiCompleted]
134
+ .map((key) => rootsByKey.get(key))
135
+ .filter(Boolean)
136
+ .toSorted((left, right) => left.localeCompare(right))
137
+ const startedAt = classification.startedAt ?? new Date().toISOString()
138
+ const updatedAt = new Date().toISOString()
139
+ await call('journey.classificationProgress', {
140
+ journeyId: view.journeyId,
141
+ kind: 'classification-progress',
142
+ id: 'current',
143
+ snapshotId: view.snapshotId,
144
+ baselineRevision: classification.baselineRevision,
145
+ proposalRevision,
146
+ inputFingerprint: inputFingerprint(view, rootIds),
147
+ stage,
148
+ status,
149
+ startedAt,
150
+ updatedAt,
151
+ elapsedMs: Math.max(0, Date.parse(updatedAt) - Date.parse(startedAt)),
152
+ rootIds,
153
+ completedRootIds,
154
+ groupedRootIds,
155
+ aiCompletedRootIds,
156
+ })
157
+ }
158
+
159
+ /** Fail only the exact running checkpoint already accepted by the backend. */
160
+ export async function publishFailedClassificationProgress(manifest, view, call) {
161
+ const classification = manifest.classification
162
+ const prior = view.classificationProgress
163
+ if (!classification || !prior || prior.status !== 'running') return
164
+ const rootIds = classification.roots
165
+ .map((root) => root.rootSessionId)
166
+ .toSorted((left, right) => left.localeCompare(right))
167
+ if (
168
+ view.revision !== classification.baselineRevision ||
169
+ prior.baselineRevision !== classification.baselineRevision ||
170
+ prior.startedAt !== classification.startedAt ||
171
+ prior.snapshotId !== view.snapshotId ||
172
+ prior.inputFingerprint !== inputFingerprint(view, rootIds) ||
173
+ JSON.stringify(prior.rootIds) !== JSON.stringify(rootIds)
174
+ )
175
+ return
176
+ const updatedAt = new Date().toISOString()
177
+ await call('journey.classificationProgress', {
178
+ journeyId: view.journeyId,
179
+ kind: 'classification-progress',
180
+ id: 'current',
181
+ snapshotId: prior.snapshotId,
182
+ baselineRevision: prior.baselineRevision,
183
+ proposalRevision: null,
184
+ inputFingerprint: prior.inputFingerprint,
185
+ stage: prior.stage,
186
+ status: 'failed',
187
+ startedAt: prior.startedAt,
188
+ updatedAt,
189
+ elapsedMs: Math.max(0, Date.parse(updatedAt) - Date.parse(prior.startedAt)),
190
+ rootIds: prior.rootIds,
191
+ completedRootIds: prior.completedRootIds,
192
+ groupedRootIds: prior.groupedRootIds,
193
+ aiCompletedRootIds: prior.aiCompletedRootIds,
194
+ })
195
+ }
@@ -0,0 +1,18 @@
1
+ export function classificationTasks(
2
+ directory: string,
3
+ manifest: {
4
+ tasks: Array<Record<string, unknown>>
5
+ classification?: {
6
+ unknown: Array<{ metadata: Record<string, unknown> }>
7
+ [key: string]: unknown
8
+ }
9
+ [key: string]: unknown
10
+ },
11
+ view: unknown,
12
+ call: (procedure: string, input: Record<string, unknown>) => Promise<unknown>,
13
+ limit: number,
14
+ ): Promise<{
15
+ tasks: Array<Record<string, unknown>>
16
+ stalled?: Array<{ id: string; ageMs: number }>
17
+ created: number
18
+ }>
@@ -0,0 +1,345 @@
1
+ import { stat } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { optional, save } from './journey-state.mjs'
4
+ import { task, taskTokens } from './journey-tasks.mjs'
5
+ import { classificationProgress } from './journey-classification-progress.mjs'
6
+
7
+ const budget = 12000
8
+ /** A task unpublished this long after it was written has a stalled child (ADR 0207). */
9
+ const STALLED_MS = 900_000
10
+ // One bounded family per line; retain all evidence while avoiding repeated pretty-print whitespace.
11
+ const formatRoots = (data) =>
12
+ `{"knownProjects":[\n${data.knownProjects.map((row) => JSON.stringify(row)).join(',\n')}\n],"families":[\n${data.families.map((row) => JSON.stringify(row)).join(',\n')}\n]}`
13
+ export const classifyInstruction =
14
+ 'Classify every assigned root family from compact metadata and partial structural project evidence. No privacy detection. Return {judgments:[{rootKey,projectName,workIntent,groupingBasis,targetProjectId?}],evidenceRootKeys:[]}. One judgment per rootKey; rootKey is an opaque handle, never invent or omit one. Before creating any new project, topic or Unsorted judgment, compare this root against every entry in knownProjects. Use targetProjectId only when that exact known project identity is supported; it must be the exact catalog projectId. Never select identity by display-name match. A targetProjectId becomes a fixed constraint in reconciliation: for genuinely cross-project roots with ambiguous or incomplete evidence, repository reference counts, change counts or apparent dominance cannot establish an owning project. Require direct evidence of this root’s owning intent before binding a target; otherwise omit targetProjectId, preserve the unresolved cross-project question in groupingBasis, and request one bounded evidence round only when it can resolve that question. An evidence request is not itself proof of ownership; bind only after its answer supports ownership. The known-project catalog is not exhaustive: an independently evidenced repository absent from it may establish a new project, including one root; omit targetProjectId for that new identity rather than treating catalog absence as Unsorted evidence. Exact complete supported repository evidence is positive identity evidence. Partial repository references are useful positive evidence but do not establish completeness; missing evidence is not negative proof. Independent content evidence can support a known project. Checking, using, connecting to, or integrating with a product/tool is not evidence that the root develops that product’s repository; require independent repository or content evidence before assigning project identity. A product topic may still be retained when roots genuinely share it. Scratch workspace name alone is insufficient, but do not ignore an exact repository reference or independent content evidence. projectName is a stable shared project or coherent theme name (<=240 chars), synthesized across the assigned roots, not a separate copy of each session title. Use the same name for roots with supported common identity or a genuinely shared topic. Use exactly Unsorted for one-off miscellaneous tasks with no supported project or meaningful shared theme; still preserve their concrete workIntent and groupingBasis so global reconciliation can reconsider them. Never create a project solely to keep a singleton. workIntent is a concrete short description of this root’s actual work, <=240 chars. If titleAvailable is false and no independent root-specific evidence establishes intent, explicitly state that intent is unknown; never invent an activity from a generated scratch/temp label or copy a sibling’s intent. Generated labels and sibling similarity alone cannot establish topic membership either. Keep such a root Unsorted with the missing evidence recorded, or request bounded evidence for a concrete membership-changing question. groupingBasis is concise evidence/uncertainty for grouping, <=200 chars. Do not produce Work descriptions, project types, visibility or recommendations here; those are assigned once during final grouping. Named ordinary source folders and corroborated project evidence can establish non-Git projects; generic home, scratch and temporary folders cannot. Titles alone do not prove a project. workspaceKind scratch, temp, home or unresolved is not an ordinary project folder: even a descriptive workspaceName may be generated from a chat title and cannot establish project identity. For those roots require independent project evidence before calling a new project; otherwise keep a theme or Unsorted and request bounded evidence only for a concrete membership-changing question. Preserve uncertainty in groupingBasis. Default evidenceRootKeys to empty. Request evidence only for a concrete unresolved identity question whose answer could change membership, and state that question in groupingBasis. Do not request evidence merely because a workspace lacks Git metadata, or blanket-request all roots of a named project your groupingBasis already supports. Missing project evidence can remain a provisional topic or Unsorted without proving a negative. A clear project remains independent with one root. Provisional topic evidence is useful across batches.'
15
+
16
+ const projectInstruction =
17
+ 'Describe fixed repository identities only. Return {items:[{projectId,description,projectType,recommendation:{access,reason}}]}. Every assigned projectId exactly once. Membership, identity and name are fixed and cannot be reclassified. Description states intended work; reason may state the grouping evidence. No original content or privacy scan. Recommend drop only when evidence shows no useful retained purpose; heterogeneous work, uncertainty or incomplete evidence alone is insufficient. Public means visible on the Passport after privacy masking; Private stays hidden but still counts. Recommend public for showcase work that demonstrates the person’s own skills, such as side projects, open-source or public repositories, coursework, hackathons, personal sites and tools, unless evidence ties it to employer or client confidentiality, internal systems or sensitive personal matters; recommend private for those. Missing privacy evidence alone is not a reason for private. Explain uncertainty in the reason. Recommendations are not approvals. Types: work, side, coursework, personal, scratch, other; access: public, private, drop.'
18
+
19
+ const rootUniverse = (view) => {
20
+ const result = []
21
+ for (const work of view.works) {
22
+ for (const rootSessionId of work.rootSessionIds) {
23
+ result.push({ rootSessionId, workId: work.id })
24
+ }
25
+ }
26
+ return result
27
+ }
28
+
29
+ async function initialize(directory, manifest, view, call) {
30
+ if (manifest.classification) return
31
+ const works = new Map(view.works.map((work) => [work.id, work]))
32
+ const universe = rootUniverse(view)
33
+ const rootWork = new Map(universe.map((entry) => [entry.rootSessionId, entry.workId]))
34
+ const startedAt = new Date().toISOString()
35
+ const routingStart = performance.now()
36
+ const page = await call('journey.catalog', {
37
+ journeyId: view.journeyId,
38
+ after: 0,
39
+ limit: 1,
40
+ includeClassificationRouting: true,
41
+ includeProjectEvidence: true,
42
+ })
43
+ const routingMs = performance.now() - routingStart
44
+ const selected = page.classificationRouting
45
+ if (!selected) throw new Error('CLASSIFICATION_ROUTING_MISSING')
46
+ const known = selected.knownProjects ?? []
47
+ const unresolved = selected.unresolvedRoots ?? []
48
+ const assignments = selected.assignments ?? []
49
+ const knownRootIds = new Set(known.flatMap((project) => project.rootSessionIds))
50
+ const unresolvedIds = new Set(unresolved.map((root) => root.rootSessionId))
51
+ const assignmentByRoot = new Map(assignments.map((entry) => [entry.rootSessionId, entry]))
52
+ if (
53
+ knownRootIds.size !== known.flatMap((project) => project.rootSessionIds).length ||
54
+ unresolvedIds.size !== unresolved.length ||
55
+ assignments.length !== universe.length ||
56
+ assignmentByRoot.size !== universe.length ||
57
+ universe.some((root) => !assignmentByRoot.has(root.rootSessionId)) ||
58
+ knownRootIds.size + unresolvedIds.size !== universe.length ||
59
+ universe.some(
60
+ (root) => !knownRootIds.has(root.rootSessionId) && !unresolvedIds.has(root.rootSessionId),
61
+ ) ||
62
+ [...knownRootIds].some((id) => !rootWork.has(id)) ||
63
+ [...unresolvedIds].some((id) => !rootWork.has(id)) ||
64
+ known.some((project) =>
65
+ project.rootSessionIds.some(
66
+ (id) => assignmentByRoot.get(id)?.projectId !== project.repositoryId,
67
+ ),
68
+ ) ||
69
+ unresolved.some((root) => assignmentByRoot.get(root.rootSessionId)?.projectId !== null)
70
+ )
71
+ throw new Error('CLASSIFICATION_ROUTING_COVERAGE_INVALID')
72
+
73
+ const roots = universe.map((entry, index) => ({
74
+ key: `r${index + 1}`,
75
+ ...entry,
76
+ }))
77
+ const byId = new Map(roots.map((root) => [root.rootSessionId, root]))
78
+ const projects = known.map((project, index) => ({
79
+ projectId: project.repositoryId,
80
+ projectName: project.repositoryName,
81
+ rootKeys: project.rootSessionIds.map((id) => byId.get(id)?.key),
82
+ metadata: project,
83
+ key: `p${String(index + 1).padStart(4, '0')}`,
84
+ }))
85
+ if (projects.some((project) => project.rootKeys.some((key) => !key)))
86
+ throw new Error('CLASSIFICATION_ROUTING_COVERAGE_INVALID')
87
+ const projectRootKeys = new Set(projects.flatMap((project) => project.rootKeys))
88
+ const unknown = unresolved.map((item) => {
89
+ const root = byId.get(item.rootSessionId)
90
+ if (!root || projectRootKeys.has(root.key))
91
+ throw new Error('CLASSIFICATION_ROUTING_COVERAGE_INVALID')
92
+ const work = works.get(root.workId)
93
+ if (!work) throw new Error('CLASSIFICATION_ROUTING_COVERAGE_INVALID')
94
+ return {
95
+ rootKey: root.key,
96
+ metadata: {
97
+ workspaceKind: item.workspaceKind,
98
+ workspaceName: item.workspaceName,
99
+ harness: item.harness,
100
+ titleAvailable: item.titleAvailable,
101
+ memberCount: item.memberCount,
102
+ titles: item.titles,
103
+ projectEvidence: item.projectEvidence ?? null,
104
+ },
105
+ }
106
+ })
107
+ if (projectRootKeys.size + unknown.length !== roots.length)
108
+ throw new Error('CLASSIFICATION_ROUTING_COVERAGE_INVALID')
109
+ manifest.classification = {
110
+ roots,
111
+ projects,
112
+ unknown,
113
+ routingAssignments: assignments,
114
+ evidence: [],
115
+ complete: false,
116
+ startedAt,
117
+ routingMs,
118
+ baselineRevision: view.revision,
119
+ }
120
+ await save(join(directory, 'manifest.json'), manifest)
121
+ }
122
+
123
+ async function pendingTasks(manifest) {
124
+ const result = []
125
+ for (const item of manifest.tasks) if (!(await optional(item.answer))) result.push(item)
126
+ return result.sort(
127
+ (left, right) =>
128
+ Number(Boolean(right.rootKeys?.length)) - Number(Boolean(left.rootKeys?.length)),
129
+ )
130
+ }
131
+
132
+ export async function classificationTasks(directory, manifest, view, call, limit) {
133
+ await initialize(directory, manifest, view, call)
134
+ const state = manifest.classification
135
+ const created = []
136
+ const availableSlots = Math.max(0, Math.floor(limit))
137
+ const knownProjects = state.projects.map(({ projectId, projectName }) => ({
138
+ projectId,
139
+ projectName,
140
+ }))
141
+ // Existing assignments are immutable. Allocate the remaining slots between
142
+ // both families by estimated payload before constructing any new tasks.
143
+ const outstandingProjects = state.projects.filter(
144
+ (project) => !manifest.tasks.some((item) => item.projectKeys?.includes(project.key)),
145
+ )
146
+ const outstandingRootKeys = new Set(state.unknown.map((item) => item.rootKey))
147
+ for (const entry of manifest.tasks)
148
+ for (const key of entry.rootKeys ?? []) outstandingRootKeys.delete(key)
149
+ const outstandingRoots = state.unknown.filter((item) => outstandingRootKeys.has(item.rootKey))
150
+ const targetSlots = Math.min(availableSlots, outstandingProjects.length + outstandingRoots.length)
151
+ const projectBaseCost = taskTokens(directory, 'projects-estimate', projectInstruction, {
152
+ projects: [],
153
+ })
154
+ const rootBaseCost = taskTokens(
155
+ directory,
156
+ 'roots-estimate',
157
+ classifyInstruction,
158
+ { knownProjects, families: [] },
159
+ formatRoots,
160
+ )
161
+ const projectCosts = new Map(
162
+ outstandingProjects.map((project) => [
163
+ project.key,
164
+ Math.max(
165
+ 1,
166
+ taskTokens(directory, 'projects-estimate', projectInstruction, {
167
+ projects: [
168
+ {
169
+ projectId: project.projectId,
170
+ projectName: project.projectName,
171
+ workspaceCount: project.metadata.workspaceIds.length,
172
+ titles: project.metadata.titles,
173
+ },
174
+ ],
175
+ }) - projectBaseCost,
176
+ ),
177
+ ]),
178
+ )
179
+ const rootCosts = new Map(
180
+ outstandingRoots.map((root) => [
181
+ root.rootKey,
182
+ Math.max(
183
+ 1,
184
+ taskTokens(
185
+ directory,
186
+ 'roots-estimate',
187
+ classifyInstruction,
188
+ { knownProjects, families: [root] },
189
+ formatRoots,
190
+ ) - rootBaseCost,
191
+ ),
192
+ ]),
193
+ )
194
+ const projectCost = (project) => projectCosts.get(project.key) ?? 1
195
+ const rootCost = (root) => rootCosts.get(root.rootKey) ?? 1
196
+ const projectTotal = outstandingProjects.reduce((sum, item) => sum + projectCost(item), 0)
197
+ const rootTotal = outstandingRoots.reduce((sum, item) => sum + rootCost(item), 0)
198
+ let projectSlots = 0
199
+ let rootSlots = 0
200
+ if (targetSlots === 1) {
201
+ if (projectTotal >= rootTotal) projectSlots = 1
202
+ else rootSlots = 1
203
+ } else if (targetSlots > 1) {
204
+ if (outstandingProjects.length && outstandingRoots.length) {
205
+ projectSlots = Math.max(
206
+ 1,
207
+ Math.min(
208
+ outstandingProjects.length,
209
+ Math.round((targetSlots * projectTotal) / (projectTotal + rootTotal)),
210
+ ),
211
+ )
212
+ rootSlots = targetSlots - projectSlots
213
+ if (rootSlots < 1) {
214
+ rootSlots = 1
215
+ projectSlots -= 1
216
+ }
217
+ if (rootSlots > outstandingRoots.length) {
218
+ projectSlots += rootSlots - outstandingRoots.length
219
+ rootSlots = outstandingRoots.length
220
+ }
221
+ if (projectSlots > outstandingProjects.length) {
222
+ rootSlots += projectSlots - outstandingProjects.length
223
+ projectSlots = outstandingProjects.length
224
+ }
225
+ } else if (outstandingProjects.length) projectSlots = targetSlots
226
+ else rootSlots = targetSlots
227
+ }
228
+
229
+ const makeProjectTasks = async () => {
230
+ const queue = [...outstandingProjects]
231
+ for (let slot = 0; slot < projectSlots && queue.length; slot += 1) {
232
+ const slotsLeft = projectSlots - slot
233
+ const maxItems = Math.max(1, queue.length - slotsLeft + 1)
234
+ const slotBudget = Math.ceil(
235
+ queue.reduce((sum, item) => sum + projectCost(item), 0) / slotsLeft,
236
+ )
237
+ const projects = []
238
+ let payload = 0
239
+ while (queue.length) {
240
+ if (projects.length >= maxItems) break
241
+ const candidate = queue[0]
242
+ const projectInput = {
243
+ projectId: candidate.projectId,
244
+ projectName: candidate.projectName,
245
+ workspaceCount: candidate.metadata.workspaceIds.length,
246
+ titles: candidate.metadata.titles,
247
+ }
248
+ const candidateInput = { projects: [...projects, projectInput] }
249
+ const cost = taskTokens(
250
+ directory,
251
+ `projects-${manifest.tasks.length}`,
252
+ projectInstruction,
253
+ candidateInput,
254
+ )
255
+ if (projects.length && (cost > budget || payload >= slotBudget)) break
256
+ if (!projects.length && cost > budget) throw new Error('CLASSIFICATION_PROJECT_TOO_LARGE')
257
+ projects.push(projectInput)
258
+ payload += projectCost(candidate)
259
+ queue.shift()
260
+ }
261
+ if (!projects.length) break
262
+ const keys = projects.map((item) => queueProjectKey(state.projects, item.projectId))
263
+ const item = await task(directory, `projects-${manifest.tasks.length}`, projectInstruction, {
264
+ projects,
265
+ })
266
+ const assigned = { ...item, projectKeys: keys }
267
+ manifest.tasks.push(assigned)
268
+ created.push(assigned)
269
+ await save(join(directory, 'manifest.json'), manifest)
270
+ }
271
+ }
272
+
273
+ const makeRootTasks = async () => {
274
+ const rootWorkByKey = new Map(state.roots.map((root) => [root.key, root.workId]))
275
+ const queue = outstandingRoots
276
+ .map((root, index) => ({ root, index }))
277
+ .sort(
278
+ (a, b) =>
279
+ String(rootWorkByKey.get(a.root.rootKey)).localeCompare(
280
+ String(rootWorkByKey.get(b.root.rootKey)),
281
+ ) || a.index - b.index,
282
+ )
283
+ .map(({ root }) => root)
284
+ for (let slot = 0; slot < rootSlots && queue.length; slot += 1) {
285
+ const slotsLeft = rootSlots - slot
286
+ const maxItems = Math.max(1, queue.length - slotsLeft + 1)
287
+ const slotBudget = Math.ceil(queue.reduce((sum, item) => sum + rootCost(item), 0) / slotsLeft)
288
+ const batch = []
289
+ let payload = 0
290
+ while (queue.length) {
291
+ if (batch.length >= maxItems) break
292
+ const candidate = [...batch, queue[0]]
293
+ const cost = taskTokens(
294
+ directory,
295
+ `roots-${manifest.tasks.length}`,
296
+ classifyInstruction,
297
+ { knownProjects, families: candidate },
298
+ formatRoots,
299
+ )
300
+ if (batch.length && (cost > budget || payload >= slotBudget)) break
301
+ if (!batch.length && cost > budget) throw new Error('CLASSIFICATION_ROOT_TOO_LARGE')
302
+ const root = queue.shift()
303
+ batch.push(root)
304
+ payload += rootCost(root)
305
+ }
306
+ if (!batch.length) break
307
+ const item = await task(
308
+ directory,
309
+ `roots-${manifest.tasks.length}`,
310
+ classifyInstruction,
311
+ { knownProjects, families: batch },
312
+ formatRoots,
313
+ )
314
+ const assigned = { ...item, rootKeys: batch.map((root) => root.rootKey), evidenceRound: 0 }
315
+ manifest.tasks.push(assigned)
316
+ created.push(assigned)
317
+ await save(join(directory, 'manifest.json'), manifest)
318
+ }
319
+ }
320
+
321
+ const queueProjectKey = (projects, projectId) =>
322
+ projects.find((item) => item.projectId === projectId)?.key
323
+ await makeProjectTasks()
324
+ await makeRootTasks()
325
+ const assignedRootKeys = new Set(manifest.tasks.flatMap((item) => item.rootKeys ?? []))
326
+ state.complete =
327
+ state.projects.every((project) =>
328
+ manifest.tasks.some((item) => item.projectKeys?.includes(project.key)),
329
+ ) && state.unknown.every((root) => assignedRootKeys.has(root.rootKey))
330
+ await save(join(directory, 'manifest.json'), manifest)
331
+ const tasks = await pendingTasks(manifest)
332
+ const now = Date.now()
333
+ const stalled = []
334
+ for (const item of tasks) {
335
+ const ageMs = now - (await stat(item.path)).mtimeMs
336
+ if (ageMs > STALLED_MS) stalled.push({ id: item.id, ageMs: Math.round(ageMs) })
337
+ }
338
+ return {
339
+ stage: 'classification',
340
+ tasks,
341
+ ...(stalled.length ? { stalled } : {}),
342
+ created: created.length,
343
+ ...(await classificationProgress(manifest)),
344
+ }
345
+ }