@cat-factory/app 0.6.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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/app/app.config.ts +8 -0
- package/app/app.vue +11 -0
- package/app/assets/css/main.css +100 -0
- package/app/components/auth/AuthGate.vue +24 -0
- package/app/components/auth/LoginScreen.vue +143 -0
- package/app/components/auth/UserMenu.vue +39 -0
- package/app/components/board/AddTaskModal.vue +444 -0
- package/app/components/board/AgentFailureCard.vue +97 -0
- package/app/components/board/AgentStopButton.vue +61 -0
- package/app/components/board/BoardCanvas.vue +183 -0
- package/app/components/board/ContextPicker.vue +367 -0
- package/app/components/board/RecurringPipelineModal.vue +219 -0
- package/app/components/board/TaskDependencyEdges.vue +132 -0
- package/app/components/board/nodes/AgentChip.vue +59 -0
- package/app/components/board/nodes/BlockNode.vue +433 -0
- package/app/components/board/nodes/DecisionBadge.vue +27 -0
- package/app/components/board/nodes/DraggableTask.vue +48 -0
- package/app/components/board/nodes/ModuleFrame.vue +97 -0
- package/app/components/board/nodes/TaskCard.vue +359 -0
- package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
- package/app/components/bootstrap/BootstrapModal.vue +665 -0
- package/app/components/clarity/ClarityReviewWindow.vue +611 -0
- package/app/components/consensus/ConsensusSessionWindow.vue +210 -0
- package/app/components/documents/DocumentImportModal.vue +161 -0
- package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
- package/app/components/documents/SpawnPreviewModal.vue +161 -0
- package/app/components/documents/TaskContextDocs.vue +83 -0
- package/app/components/focus/BlockFocusView.vue +171 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
- package/app/components/gates/GateResultView.vue +282 -0
- package/app/components/github/AddServiceFromRepoModal.vue +354 -0
- package/app/components/github/GitHubConnect.vue +183 -0
- package/app/components/github/GitHubOnboarding.vue +45 -0
- package/app/components/github/GitHubPanel.vue +584 -0
- package/app/components/github/RepoTreeBrowser.vue +171 -0
- package/app/components/layout/AccountTeamSettings.vue +237 -0
- package/app/components/layout/BoardSwitcher.vue +280 -0
- package/app/components/layout/BoardToolbar.vue +156 -0
- package/app/components/layout/CommandBar.vue +336 -0
- package/app/components/layout/GitHubPatBanner.vue +73 -0
- package/app/components/layout/NotificationsInbox.vue +175 -0
- package/app/components/layout/SideBar.vue +314 -0
- package/app/components/layout/SpendWarningBanner.vue +107 -0
- package/app/components/observability/StepMetricsBar.vue +102 -0
- package/app/components/palettes/AgentPalette.vue +86 -0
- package/app/components/panels/AgentStepDetail.vue +737 -0
- package/app/components/panels/DecisionModal.vue +71 -0
- package/app/components/panels/InspectorPanel.vue +465 -0
- package/app/components/panels/ObservabilityPanel.vue +351 -0
- package/app/components/panels/StepMetadataCard.vue +253 -0
- package/app/components/panels/StepRestartControl.vue +90 -0
- package/app/components/panels/StepResultViewHost.vue +40 -0
- package/app/components/panels/StepTestReport.vue +84 -0
- package/app/components/panels/inspector/ContainerSummary.vue +74 -0
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
- package/app/components/panels/inspector/ServiceFragments.vue +82 -0
- package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
- package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
- package/app/components/panels/inspector/TaskDependencies.vue +70 -0
- package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
- package/app/components/panels/inspector/TaskExecution.vue +364 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
- package/app/components/panels/inspector/TaskStructure.vue +96 -0
- package/app/components/pipeline/AgentKindIcon.vue +30 -0
- package/app/components/pipeline/IterationCapPrompt.vue +70 -0
- package/app/components/pipeline/PipelineBuilder.vue +817 -0
- package/app/components/pipeline/PipelineProgress.vue +484 -0
- package/app/components/providers/ApiKeysSection.vue +273 -0
- package/app/components/providers/PersonalCredentialModal.vue +128 -0
- package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
- package/app/components/providers/VendorCredentialsModal.vue +197 -0
- package/app/components/recurring/RecurrenceEditor.vue +124 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
- package/app/components/settings/DatadogPanel.vue +213 -0
- package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
- package/app/components/settings/MergeThresholdsPanel.vue +378 -0
- package/app/components/settings/ModelDefaultsPanel.vue +250 -0
- package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
- package/app/components/slack/SlackPanel.vue +299 -0
- package/app/components/tasks/TaskContextIssues.vue +88 -0
- package/app/components/tasks/TaskImportModal.vue +207 -0
- package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
- package/app/components/testing/TestReportWindow.vue +404 -0
- package/app/composables/api/accounts.ts +81 -0
- package/app/composables/api/auth.ts +45 -0
- package/app/composables/api/board.ts +101 -0
- package/app/composables/api/bootstrap.ts +62 -0
- package/app/composables/api/context.ts +25 -0
- package/app/composables/api/documents.ts +74 -0
- package/app/composables/api/execution.ts +127 -0
- package/app/composables/api/fragments.ts +71 -0
- package/app/composables/api/github.ts +131 -0
- package/app/composables/api/models.ts +127 -0
- package/app/composables/api/notifications.ts +23 -0
- package/app/composables/api/presets.ts +29 -0
- package/app/composables/api/recurring.ts +68 -0
- package/app/composables/api/releaseHealth.ts +43 -0
- package/app/composables/api/reviews.ts +146 -0
- package/app/composables/api/slack.ts +54 -0
- package/app/composables/api/tasks.ts +72 -0
- package/app/composables/api/workspaces.ts +36 -0
- package/app/composables/useApi.ts +89 -0
- package/app/composables/useBlockDrag.ts +90 -0
- package/app/composables/useBlockQueries.ts +154 -0
- package/app/composables/useBoardFlow.ts +11 -0
- package/app/composables/useContextLinking.ts +65 -0
- package/app/composables/useDepLabels.ts +26 -0
- package/app/composables/useFrameResize.ts +54 -0
- package/app/composables/useResultView.ts +48 -0
- package/app/composables/useReviewStage.ts +40 -0
- package/app/composables/useSemanticZoom.ts +31 -0
- package/app/composables/useStepApproval.ts +233 -0
- package/app/composables/useStepProse.ts +78 -0
- package/app/composables/useStepTimer.ts +63 -0
- package/app/composables/useTaskExpansion.ts +92 -0
- package/app/composables/useWorkspaceStream.ts +155 -0
- package/app/docs/architecture.md +31 -0
- package/app/pages/index.vue +141 -0
- package/app/stores/accounts.ts +152 -0
- package/app/stores/agentConfig.ts +35 -0
- package/app/stores/agentRuns.ts +122 -0
- package/app/stores/agents.ts +40 -0
- package/app/stores/apiKeys.ts +108 -0
- package/app/stores/auth.ts +166 -0
- package/app/stores/board.spec.ts +205 -0
- package/app/stores/board.ts +286 -0
- package/app/stores/bootstrap.ts +97 -0
- package/app/stores/clarity.ts +196 -0
- package/app/stores/consensus.ts +60 -0
- package/app/stores/documents.ts +176 -0
- package/app/stores/execution.ts +273 -0
- package/app/stores/fragmentLibrary.ts +147 -0
- package/app/stores/fragments.ts +40 -0
- package/app/stores/github.ts +305 -0
- package/app/stores/localModels.ts +51 -0
- package/app/stores/mergePresets.ts +58 -0
- package/app/stores/modelDefaults.ts +76 -0
- package/app/stores/models.ts +134 -0
- package/app/stores/notifications.ts +70 -0
- package/app/stores/observability.ts +144 -0
- package/app/stores/personalSubscriptions.ts +215 -0
- package/app/stores/pipelines.ts +327 -0
- package/app/stores/recurringPipelines.ts +112 -0
- package/app/stores/releaseHealth.ts +75 -0
- package/app/stores/requirements.spec.ts +94 -0
- package/app/stores/requirements.ts +208 -0
- package/app/stores/serviceFragmentDefaults.ts +29 -0
- package/app/stores/services.ts +87 -0
- package/app/stores/slack.ts +142 -0
- package/app/stores/taskExpansion.ts +36 -0
- package/app/stores/tasks.spec.ts +71 -0
- package/app/stores/tasks.ts +176 -0
- package/app/stores/tracker.ts +27 -0
- package/app/stores/ui.ts +434 -0
- package/app/stores/vendorCredentials.ts +54 -0
- package/app/stores/workspace.ts +215 -0
- package/app/stores/workspaceSettings.ts +36 -0
- package/app/types/accounts.ts +77 -0
- package/app/types/bootstrap.ts +83 -0
- package/app/types/clarity.ts +59 -0
- package/app/types/consensus.ts +91 -0
- package/app/types/documents.ts +104 -0
- package/app/types/domain.ts +495 -0
- package/app/types/execution.ts +383 -0
- package/app/types/fragments.ts +72 -0
- package/app/types/github.ts +173 -0
- package/app/types/localModels.ts +73 -0
- package/app/types/merge.ts +71 -0
- package/app/types/models.ts +157 -0
- package/app/types/notifications.ts +74 -0
- package/app/types/recurring.ts +69 -0
- package/app/types/releaseHealth.ts +31 -0
- package/app/types/requirements.ts +61 -0
- package/app/types/services.ts +27 -0
- package/app/types/slack.ts +57 -0
- package/app/types/tasks.ts +82 -0
- package/app/types/tracker.ts +18 -0
- package/app/utils/agentOutput.spec.ts +128 -0
- package/app/utils/agentOutput.ts +173 -0
- package/app/utils/catalog.spec.ts +112 -0
- package/app/utils/catalog.ts +455 -0
- package/app/utils/dnd.ts +29 -0
- package/app/utils/observability.ts +52 -0
- package/app/utils/pipelineRender.ts +151 -0
- package/nuxt.config.ts +55 -0
- package/package.json +45 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parseOutputOutline, sliceSource } from '~/utils/agentOutput'
|
|
3
|
+
|
|
4
|
+
describe('parseOutputOutline', () => {
|
|
5
|
+
it('splits on headings and builds a ToC', () => {
|
|
6
|
+
const out = parseOutputOutline(
|
|
7
|
+
['# Overview', 'intro text', '', '## Findings', '- a', '- b'].join('\n'),
|
|
8
|
+
)
|
|
9
|
+
expect(out.hasToc).toBe(true)
|
|
10
|
+
expect(out.minDepth).toBe(1)
|
|
11
|
+
expect(out.sections.map((s) => s.title)).toEqual(['Overview', 'Findings'])
|
|
12
|
+
expect(out.sections[0]!.depth).toBe(1)
|
|
13
|
+
expect(out.sections[1]!.depth).toBe(2)
|
|
14
|
+
const findings = out.sections[1]!.bodyHtml
|
|
15
|
+
// top-level blocks now carry data-src-* anchors, so match the open tag loosely
|
|
16
|
+
expect(findings).toContain('<ul')
|
|
17
|
+
expect(findings).toContain('<li>a</li>')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('keeps text before the first heading as an untitled preamble (no ToC entry)', () => {
|
|
21
|
+
const out = parseOutputOutline('loose intro\n\n## Section')
|
|
22
|
+
expect(out.sections[0]!.title).toBe('')
|
|
23
|
+
expect(out.sections[0]!.depth).toBe(0)
|
|
24
|
+
expect(out.sections[0]!.bodyHtml).toContain('loose intro')
|
|
25
|
+
expect(out.sections.filter((s) => s.depth > 0)).toHaveLength(1)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('reports no ToC when there are no headings', () => {
|
|
29
|
+
const out = parseOutputOutline('just a paragraph of prose')
|
|
30
|
+
expect(out.hasToc).toBe(false)
|
|
31
|
+
expect(out.sections).toHaveLength(1)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('gives every section a unique id even with duplicate titles', () => {
|
|
35
|
+
const out = parseOutputOutline('## Risks\na\n## Risks\nb')
|
|
36
|
+
const ids = out.sections.map((s) => s.id)
|
|
37
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('captures fenced code verbatim without treating its lines as headings', () => {
|
|
41
|
+
const out = parseOutputOutline('## Code\n```ts\nconst x = 1\n# not a heading\n```')
|
|
42
|
+
expect(out.sections).toHaveLength(1)
|
|
43
|
+
const body = out.sections[0]!.bodyHtml
|
|
44
|
+
expect(body).toContain('<pre>')
|
|
45
|
+
expect(body).toContain('# not a heading')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('escapes raw HTML rather than injecting it (html: false)', () => {
|
|
49
|
+
const out = parseOutputOutline('## S\n<img src=x onerror=alert(1)>')
|
|
50
|
+
const body = out.sections[0]!.bodyHtml
|
|
51
|
+
expect(body).not.toContain('<img')
|
|
52
|
+
expect(body).toContain('<img')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders inline marks and decorates links to open safely in a new tab', () => {
|
|
56
|
+
const out = parseOutputOutline('## S\nsee **bold** and [site](https://example.com)')
|
|
57
|
+
const body = out.sections[0]!.bodyHtml
|
|
58
|
+
expect(body).toContain('<strong>bold</strong>')
|
|
59
|
+
expect(body).toContain('href="https://example.com"')
|
|
60
|
+
expect(body).toContain('target="_blank"')
|
|
61
|
+
expect(body).toContain('rel="noopener noreferrer"')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('does not create a link for javascript: URLs (markdown-it validateLink)', () => {
|
|
65
|
+
// validateLink rejects the scheme, so it stays inert plain text — never an <a>.
|
|
66
|
+
const out = parseOutputOutline('## S\n[x](javascript:alert(1))')
|
|
67
|
+
const body = out.sections[0]!.bodyHtml
|
|
68
|
+
expect(body).not.toContain('<a ')
|
|
69
|
+
expect(body).not.toContain('href')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('tolerates empty / nullish input', () => {
|
|
73
|
+
expect(parseOutputOutline('').sections).toHaveLength(0)
|
|
74
|
+
expect(parseOutputOutline(undefined as unknown as string).sections).toHaveLength(0)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('sliceSource', () => {
|
|
79
|
+
it('returns the verbatim line range (0-based, end-exclusive)', () => {
|
|
80
|
+
const text = ['line0', 'line1', 'line2', 'line3'].join('\n')
|
|
81
|
+
expect(sliceSource(text, 1, 3)).toBe('line1\nline2')
|
|
82
|
+
expect(sliceSource(text, 0, 1)).toBe('line0')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('tolerates nullish input', () => {
|
|
86
|
+
expect(sliceSource(undefined as unknown as string, 0, 1)).toBe('')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('source-line stamping (approval-mode block anchors)', () => {
|
|
91
|
+
// Find a rendered block by its text and round-trip its `data-src-*` range back
|
|
92
|
+
// through `sliceSource` against the ORIGINAL output — this is the contract the
|
|
93
|
+
// approval reader relies on to quote the agent's own markdown back to it.
|
|
94
|
+
const blockFor = (output: string, contains: string) => {
|
|
95
|
+
const { sections } = parseOutputOutline(output)
|
|
96
|
+
const host = document.createElement('div')
|
|
97
|
+
host.innerHTML = sections.map((s) => s.bodyHtml).join('\n')
|
|
98
|
+
const el = Array.from(host.querySelectorAll('[data-src-start]')).find((e) =>
|
|
99
|
+
(e.textContent ?? '').includes(contains),
|
|
100
|
+
)
|
|
101
|
+
if (!el) throw new Error(`no source-stamped block containing "${contains}"`)
|
|
102
|
+
return {
|
|
103
|
+
start: Number(el.getAttribute('data-src-start')),
|
|
104
|
+
end: Number(el.getAttribute('data-src-end')),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
it('round-trips a paragraph block to its original source lines', () => {
|
|
109
|
+
const output = ['## Summary', '', 'First paragraph.', '', 'Second paragraph here.'].join('\n')
|
|
110
|
+
const { start, end } = blockFor(output, 'First paragraph.')
|
|
111
|
+
expect(sliceSource(output, start, end)).toBe('First paragraph.')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('round-trips a multi-line fenced code block verbatim', () => {
|
|
115
|
+
const code = ['```ts', 'const x = 1', 'const y = 2', '```'].join('\n')
|
|
116
|
+
const output = ['## Code', '', code].join('\n')
|
|
117
|
+
const { start, end } = blockFor(output, 'const x = 1')
|
|
118
|
+
expect(sliceSource(output, start, end)).toBe(code)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('stamps top-level blocks only (a comment targets a whole block, not a nested item)', () => {
|
|
122
|
+
const output = ['Intro paragraph.', '', '- item one', '- item two'].join('\n')
|
|
123
|
+
const { start, end } = blockFor(output, 'item one')
|
|
124
|
+
// The whole list is the top-level block, so the slice spans both items.
|
|
125
|
+
expect(sliceSource(output, start, end)).toContain('- item one')
|
|
126
|
+
expect(sliceSource(output, start, end)).toContain('- item two')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Turn an agent's prose output (markdown) into a heading-delimited outline so it
|
|
2
|
+
// can be read in the dedicated reader overlay: a navigable table of contents on
|
|
3
|
+
// one side, collapsible sections on the other.
|
|
4
|
+
//
|
|
5
|
+
// Markdown → HTML is done by `markdown-it` (a mature CommonMark parser) with
|
|
6
|
+
// `html: false`, so it is secure by default: any raw HTML in the agent's output
|
|
7
|
+
// is escaped rather than injected, and its `validateLink` blocks dangerous URL
|
|
8
|
+
// schemes — no separate sanitizer needed for the LLM-generated text we feed it.
|
|
9
|
+
// This module only adds the one thing markdown-it doesn't: SEGMENTATION — split
|
|
10
|
+
// the rendered document at each heading into sections we can collapse
|
|
11
|
+
// independently and link from a ToC. That split is done over the parsed DOM, so
|
|
12
|
+
// it is independent of markdown-it's token internals.
|
|
13
|
+
import MarkdownIt from 'markdown-it'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stamp every TOP-LEVEL block element with its source line range
|
|
17
|
+
* (`data-src-start`/`data-src-end`, 0-based, end-exclusive — straight from
|
|
18
|
+
* markdown-it's `token.map`). The approval-mode reader uses these to let a human
|
|
19
|
+
* comment on a specific block and quote that block's verbatim raw markdown back to
|
|
20
|
+
* the agent on a "request changes" re-run. Only top-level blocks are tagged (depth
|
|
21
|
+
* tracked over the flat token stream) so a comment targets a whole paragraph/list/
|
|
22
|
+
* heading rather than a nested fragment.
|
|
23
|
+
*/
|
|
24
|
+
function sourceLinePlugin(md: MarkdownIt): void {
|
|
25
|
+
md.core.ruler.push('source_lines', (state) => {
|
|
26
|
+
let depth = 0
|
|
27
|
+
for (const token of state.tokens) {
|
|
28
|
+
const atTopLevel = depth === 0
|
|
29
|
+
// Annotate a top-level block's opening token (nesting 1) or a self-contained
|
|
30
|
+
// block token (nesting 0, e.g. fence/hr/code_block/html_block).
|
|
31
|
+
if (atTopLevel && token.block && token.type !== 'inline' && token.map && token.nesting >= 0) {
|
|
32
|
+
token.attrSet('data-src-start', String(token.map[0]))
|
|
33
|
+
token.attrSet('data-src-end', String(token.map[1]))
|
|
34
|
+
}
|
|
35
|
+
if (token.nesting === 1) depth++
|
|
36
|
+
else if (token.nesting === -1) depth--
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The verbatim raw-markdown source of a block, given the original output text and a
|
|
44
|
+
* 0-based, end-exclusive line range (as captured from `data-src-start/end`).
|
|
45
|
+
*/
|
|
46
|
+
export function sliceSource(output: string, start: number, end: number): string {
|
|
47
|
+
return (output ?? '').split('\n').slice(start, end).join('\n')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One heading-delimited section. `depth` 0 / empty `title` is the preamble that
|
|
51
|
+
* precedes the first heading (rendered, but never shown in the ToC). */
|
|
52
|
+
export interface OutputSection {
|
|
53
|
+
id: string
|
|
54
|
+
depth: number
|
|
55
|
+
/** Plain-text heading, for the ToC. */
|
|
56
|
+
title: string
|
|
57
|
+
/** Inline-rendered heading HTML (code/bold/… preserved), for the section header. */
|
|
58
|
+
titleHtml: string
|
|
59
|
+
/** HTML of everything under this heading up to the next one. */
|
|
60
|
+
bodyHtml: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface OutputOutline {
|
|
64
|
+
sections: OutputSection[]
|
|
65
|
+
/** True once there is at least one real heading worth a ToC entry. */
|
|
66
|
+
hasToc: boolean
|
|
67
|
+
/** Shallowest heading depth present, so the ToC can indent relative to it. */
|
|
68
|
+
minDepth: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const md = new MarkdownIt({
|
|
72
|
+
html: false, // secure by default: escape raw HTML rather than render it
|
|
73
|
+
linkify: true, // turn bare URLs into links
|
|
74
|
+
breaks: true, // single newlines → <br>, matching how agents lay out prose
|
|
75
|
+
typographer: true,
|
|
76
|
+
}).use(sourceLinePlugin)
|
|
77
|
+
|
|
78
|
+
const HEADINGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6'])
|
|
79
|
+
const LINK_CLASS = 'text-indigo-300 underline decoration-indigo-500/40 hover:text-indigo-200'
|
|
80
|
+
|
|
81
|
+
function slugify(title: string, used: Set<string>): string {
|
|
82
|
+
const base =
|
|
83
|
+
title
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
86
|
+
.replace(/^-+|-+$/g, '') || 'section'
|
|
87
|
+
let slug = base
|
|
88
|
+
let n = 2
|
|
89
|
+
while (used.has(slug)) slug = `${base}-${n++}`
|
|
90
|
+
used.add(slug)
|
|
91
|
+
return slug
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Make every link open safely in a new tab and pick up the reader's link style. */
|
|
95
|
+
function decorateLinks(root: HTMLElement): void {
|
|
96
|
+
for (const a of Array.from(root.querySelectorAll('a'))) {
|
|
97
|
+
a.setAttribute('target', '_blank')
|
|
98
|
+
a.setAttribute('rel', 'noopener noreferrer')
|
|
99
|
+
a.setAttribute('class', LINK_CLASS)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Build the heading-based outline for an agent's prose output. */
|
|
104
|
+
export function parseOutputOutline(text: string): OutputOutline {
|
|
105
|
+
const source = text ?? ''
|
|
106
|
+
if (!source.trim()) return { sections: [], hasToc: false, minDepth: 1 }
|
|
107
|
+
|
|
108
|
+
const html = md.render(source)
|
|
109
|
+
|
|
110
|
+
// No DOM (SSR / non-browser): fall back to one un-segmented section. The reader
|
|
111
|
+
// overlay is client-only, so this path is effectively dead outside any runtime
|
|
112
|
+
// without `document`.
|
|
113
|
+
if (typeof document === 'undefined') {
|
|
114
|
+
return {
|
|
115
|
+
sections: [{ id: 'overview', depth: 0, title: '', titleHtml: '', bodyHtml: html }],
|
|
116
|
+
hasToc: false,
|
|
117
|
+
minDepth: 1,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const root = document.createElement('div')
|
|
122
|
+
root.innerHTML = html
|
|
123
|
+
decorateLinks(root)
|
|
124
|
+
|
|
125
|
+
const used = new Set<string>()
|
|
126
|
+
const sections: OutputSection[] = []
|
|
127
|
+
let current: OutputSection | null = null
|
|
128
|
+
let body: HTMLElement | null = null
|
|
129
|
+
|
|
130
|
+
const flush = () => {
|
|
131
|
+
if (current && body) current.bodyHtml = body.innerHTML.trim()
|
|
132
|
+
if (current) sections.push(current)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const node of Array.from(root.childNodes)) {
|
|
136
|
+
const el = node.nodeType === 1 ? (node as HTMLElement) : null
|
|
137
|
+
if (el && HEADINGS.has(el.tagName)) {
|
|
138
|
+
flush()
|
|
139
|
+
const title = (el.textContent ?? '').trim()
|
|
140
|
+
current = {
|
|
141
|
+
id: slugify(title, used),
|
|
142
|
+
depth: Number(el.tagName[1]),
|
|
143
|
+
title,
|
|
144
|
+
titleHtml: el.innerHTML,
|
|
145
|
+
bodyHtml: '',
|
|
146
|
+
}
|
|
147
|
+
body = document.createElement('div')
|
|
148
|
+
} else {
|
|
149
|
+
if (!current) {
|
|
150
|
+
// Content before the first heading → untitled preamble section.
|
|
151
|
+
current = {
|
|
152
|
+
id: slugify('overview', used),
|
|
153
|
+
depth: 0,
|
|
154
|
+
title: '',
|
|
155
|
+
titleHtml: '',
|
|
156
|
+
bodyHtml: '',
|
|
157
|
+
}
|
|
158
|
+
body = document.createElement('div')
|
|
159
|
+
}
|
|
160
|
+
body!.appendChild(node.cloneNode(true))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
flush()
|
|
164
|
+
|
|
165
|
+
// A preamble that turned out to hold nothing renderable is noise — drop it.
|
|
166
|
+
const cleaned = sections.filter((s) => s.title || s.bodyHtml)
|
|
167
|
+
const headed = cleaned.filter((s) => s.depth > 0)
|
|
168
|
+
return {
|
|
169
|
+
sections: cleaned,
|
|
170
|
+
hasToc: headed.length > 0,
|
|
171
|
+
minDepth: headed.length ? Math.min(...headed.map((s) => s.depth)) : 1,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { AgentKind, BlockStatus, BlockType } from '~/types/domain'
|
|
3
|
+
import {
|
|
4
|
+
AGENT_ARCHETYPES,
|
|
5
|
+
AGENT_BY_KIND,
|
|
6
|
+
BLOCK_TYPE_META,
|
|
7
|
+
STATUS_META,
|
|
8
|
+
SYSTEM_AGENT_META,
|
|
9
|
+
agentKindMeta,
|
|
10
|
+
uid,
|
|
11
|
+
} from '~/utils/catalog'
|
|
12
|
+
|
|
13
|
+
const AGENT_KINDS: AgentKind[] = [
|
|
14
|
+
'requirements-review',
|
|
15
|
+
'clarity-review',
|
|
16
|
+
'bug-investigator',
|
|
17
|
+
'task-estimator',
|
|
18
|
+
'architect',
|
|
19
|
+
'researcher',
|
|
20
|
+
'coder',
|
|
21
|
+
'tester',
|
|
22
|
+
'reviewer',
|
|
23
|
+
'documenter',
|
|
24
|
+
'integrator',
|
|
25
|
+
'architect-companion',
|
|
26
|
+
'spec-companion',
|
|
27
|
+
'playwright',
|
|
28
|
+
'mocker',
|
|
29
|
+
'business-documenter',
|
|
30
|
+
'business-reviewer',
|
|
31
|
+
]
|
|
32
|
+
const BLOCK_TYPES: BlockType[] = [
|
|
33
|
+
'frontend',
|
|
34
|
+
'service',
|
|
35
|
+
'api',
|
|
36
|
+
'database',
|
|
37
|
+
'queue',
|
|
38
|
+
'integration',
|
|
39
|
+
'external',
|
|
40
|
+
]
|
|
41
|
+
const BLOCK_STATUSES: BlockStatus[] = [
|
|
42
|
+
'planned',
|
|
43
|
+
'ready',
|
|
44
|
+
'in_progress',
|
|
45
|
+
'blocked',
|
|
46
|
+
'pr_ready',
|
|
47
|
+
'done',
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
describe('catalog', () => {
|
|
51
|
+
it('indexes every archetype by its kind', () => {
|
|
52
|
+
expect(Object.keys(AGENT_BY_KIND).sort()).toEqual([...AGENT_KINDS].sort())
|
|
53
|
+
for (const a of AGENT_ARCHETYPES) {
|
|
54
|
+
expect(AGENT_BY_KIND[a.kind]).toBe(a)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('resolves usable metadata for every kind via agentKindMeta', () => {
|
|
59
|
+
// Palette archetypes resolve to their own entry.
|
|
60
|
+
for (const a of AGENT_ARCHETYPES) {
|
|
61
|
+
expect(agentKindMeta(a.kind)).toBe(a)
|
|
62
|
+
}
|
|
63
|
+
// Engine system kinds (present in seeded pipelines but not the palette) resolve
|
|
64
|
+
// to their system metadata rather than blowing up an undefined access.
|
|
65
|
+
for (const kind of [
|
|
66
|
+
'spec-writer',
|
|
67
|
+
'blueprints',
|
|
68
|
+
'conflicts',
|
|
69
|
+
'conflict-resolver',
|
|
70
|
+
'ci',
|
|
71
|
+
'ci-fixer',
|
|
72
|
+
'merger',
|
|
73
|
+
'post-release-health',
|
|
74
|
+
]) {
|
|
75
|
+
expect(agentKindMeta(kind)).toBe(SYSTEM_AGENT_META[kind])
|
|
76
|
+
expect(agentKindMeta(kind).icon).toEqual(expect.any(String))
|
|
77
|
+
}
|
|
78
|
+
// An unknown/custom kind still returns a usable fallback (never undefined).
|
|
79
|
+
const unknown = agentKindMeta('totally-made-up')
|
|
80
|
+
expect(unknown).toMatchObject({
|
|
81
|
+
label: expect.any(String),
|
|
82
|
+
icon: expect.any(String),
|
|
83
|
+
color: expect.any(String),
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('provides metadata for every block type', () => {
|
|
88
|
+
for (const t of BLOCK_TYPES) {
|
|
89
|
+
expect(BLOCK_TYPE_META[t]).toMatchObject({
|
|
90
|
+
label: expect.any(String),
|
|
91
|
+
icon: expect.any(String),
|
|
92
|
+
accent: expect.any(String),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('provides metadata for every block status', () => {
|
|
98
|
+
for (const s of BLOCK_STATUSES) {
|
|
99
|
+
expect(STATUS_META[s]).toMatchObject({
|
|
100
|
+
label: expect.any(String),
|
|
101
|
+
color: expect.any(String),
|
|
102
|
+
chip: expect.any(String),
|
|
103
|
+
icon: expect.any(String),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('uid produces prefixed, unique-ish ids', () => {
|
|
109
|
+
expect(uid('blk')).toMatch(/^blk_[a-z0-9]+$/)
|
|
110
|
+
expect(uid('blk')).not.toBe(uid('blk'))
|
|
111
|
+
})
|
|
112
|
+
})
|