@cat-factory/app 0.6.0 → 0.7.3

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 (33) hide show
  1. package/LICENSE +21 -21
  2. package/app/components/board/ContextPicker.vue +367 -367
  3. package/app/components/gates/GateResultView.vue +90 -12
  4. package/app/components/layout/SideBar.vue +11 -0
  5. package/app/components/observability/StepMetricsBar.vue +102 -102
  6. package/app/components/observability/StepModelActivity.vue +49 -0
  7. package/app/components/panels/ObservabilityPanel.vue +1 -1
  8. package/app/components/panels/StepMetadataCard.vue +4 -16
  9. package/app/components/panels/StepRunMeta.vue +105 -0
  10. package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -178
  11. package/app/components/panels/inspector/TaskRunSettings.vue +77 -0
  12. package/app/components/recurring/RecurrenceEditor.vue +124 -124
  13. package/app/components/settings/IssueTrackerWritebackPanel.vue +103 -0
  14. package/app/components/testing/TestReportWindow.vue +17 -8
  15. package/app/composables/useBlockQueries.ts +154 -154
  16. package/app/composables/useContextLinking.ts +65 -65
  17. package/app/composables/useFrameResize.ts +54 -54
  18. package/app/pages/index.vue +2 -0
  19. package/app/stores/documents.ts +176 -176
  20. package/app/stores/services.ts +87 -87
  21. package/app/stores/tracker.ts +39 -27
  22. package/app/stores/ui.ts +12 -0
  23. package/app/types/documents.ts +104 -104
  24. package/app/types/domain.ts +5 -1
  25. package/app/types/execution.ts +18 -0
  26. package/app/types/github.ts +173 -173
  27. package/app/types/services.ts +27 -27
  28. package/app/types/tasks.ts +82 -82
  29. package/app/types/tracker.ts +27 -18
  30. package/app/utils/agentOutput.spec.ts +128 -128
  31. package/app/utils/agentOutput.ts +173 -173
  32. package/app/utils/observability.ts +52 -52
  33. package/package.json +6 -1
@@ -1,82 +1,82 @@
1
- // ---------------------------------------------------------------------------
2
- // Task-source integration. Individual issues imported from external task
3
- // trackers (Jira, …) can be attached to a board task as agent context. These
4
- // mirror the `@cat-factory/contracts` task schemas; the abstraction is
5
- // source-agnostic, keyed by `source`. Unlike document sources there is no
6
- // plan/spawn — an issue is linked for context, never expanded into structure.
7
- // ---------------------------------------------------------------------------
8
-
9
- import type { CredentialField } from './documents'
10
-
11
- /** The external task trackers cat-factory can link to. */
12
- export type TaskSourceKind = 'jira' | 'github'
13
-
14
- export type { CredentialField }
15
-
16
- /** A source's self-description: drives the generic connect + import UI. */
17
- export interface TaskSourceDescriptor {
18
- source: TaskSourceKind
19
- label: string
20
- /** Lucide icon name for the source. */
21
- icon: string
22
- credentialFields: CredentialField[]
23
- refLabel: string
24
- refPlaceholder: string
25
- /** Whether the source supports searching its catalogue by title/content. */
26
- searchable?: boolean
27
- }
28
-
29
- /** A workspace's connection to a task source (never carries credentials). */
30
- export interface TaskConnection {
31
- source: TaskSourceKind
32
- /** Human-friendly label for what we're connected to (site URL). */
33
- label: string
34
- /** When the connection was established (epoch ms). */
35
- connectedAt: number
36
- }
37
-
38
- /** A single comment on an issue, with its body as lightweight Markdown. */
39
- export interface TaskComment {
40
- author: string
41
- createdAt: string
42
- body: string
43
- }
44
-
45
- /** An issue imported from a source into the workspace, as a structured record. */
46
- export interface SourceTask {
47
- source: TaskSourceKind
48
- /** The source's canonical key for the issue (e.g. `PROJ-123`). */
49
- externalId: string
50
- title: string
51
- url: string
52
- /** Workflow status name, e.g. `In Progress`. */
53
- status: string
54
- /** Issue type name, e.g. `Bug`. */
55
- type: string
56
- /** Assignee display name, or null when unassigned. */
57
- assignee: string | null
58
- /** Priority name, or null when none. */
59
- priority: string | null
60
- labels: string[]
61
- /** Issue description as lightweight Markdown. */
62
- description: string
63
- comments: TaskComment[]
64
- /** Short plain-text preview of the issue. */
65
- excerpt: string
66
- /** The board block this issue is attached to as context, if any. */
67
- linkedBlockId: string | null
68
- syncedAt: number
69
- }
70
-
71
- /** A lean hit from searching a tracker's issues (not yet imported). */
72
- export interface TaskSearchResult {
73
- source: TaskSourceKind
74
- /** The source's canonical key for the issue (re-usable as an import ref). */
75
- externalId: string
76
- title: string
77
- url: string
78
- /** Workflow status name, e.g. `In Progress` (may be empty). */
79
- status: string
80
- /** Short plain-text preview (may be empty). */
81
- excerpt: string
82
- }
1
+ // ---------------------------------------------------------------------------
2
+ // Task-source integration. Individual issues imported from external task
3
+ // trackers (Jira, …) can be attached to a board task as agent context. These
4
+ // mirror the `@cat-factory/contracts` task schemas; the abstraction is
5
+ // source-agnostic, keyed by `source`. Unlike document sources there is no
6
+ // plan/spawn — an issue is linked for context, never expanded into structure.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import type { CredentialField } from './documents'
10
+
11
+ /** The external task trackers cat-factory can link to. */
12
+ export type TaskSourceKind = 'jira' | 'github'
13
+
14
+ export type { CredentialField }
15
+
16
+ /** A source's self-description: drives the generic connect + import UI. */
17
+ export interface TaskSourceDescriptor {
18
+ source: TaskSourceKind
19
+ label: string
20
+ /** Lucide icon name for the source. */
21
+ icon: string
22
+ credentialFields: CredentialField[]
23
+ refLabel: string
24
+ refPlaceholder: string
25
+ /** Whether the source supports searching its catalogue by title/content. */
26
+ searchable?: boolean
27
+ }
28
+
29
+ /** A workspace's connection to a task source (never carries credentials). */
30
+ export interface TaskConnection {
31
+ source: TaskSourceKind
32
+ /** Human-friendly label for what we're connected to (site URL). */
33
+ label: string
34
+ /** When the connection was established (epoch ms). */
35
+ connectedAt: number
36
+ }
37
+
38
+ /** A single comment on an issue, with its body as lightweight Markdown. */
39
+ export interface TaskComment {
40
+ author: string
41
+ createdAt: string
42
+ body: string
43
+ }
44
+
45
+ /** An issue imported from a source into the workspace, as a structured record. */
46
+ export interface SourceTask {
47
+ source: TaskSourceKind
48
+ /** The source's canonical key for the issue (e.g. `PROJ-123`). */
49
+ externalId: string
50
+ title: string
51
+ url: string
52
+ /** Workflow status name, e.g. `In Progress`. */
53
+ status: string
54
+ /** Issue type name, e.g. `Bug`. */
55
+ type: string
56
+ /** Assignee display name, or null when unassigned. */
57
+ assignee: string | null
58
+ /** Priority name, or null when none. */
59
+ priority: string | null
60
+ labels: string[]
61
+ /** Issue description as lightweight Markdown. */
62
+ description: string
63
+ comments: TaskComment[]
64
+ /** Short plain-text preview of the issue. */
65
+ excerpt: string
66
+ /** The board block this issue is attached to as context, if any. */
67
+ linkedBlockId: string | null
68
+ syncedAt: number
69
+ }
70
+
71
+ /** A lean hit from searching a tracker's issues (not yet imported). */
72
+ export interface TaskSearchResult {
73
+ source: TaskSourceKind
74
+ /** The source's canonical key for the issue (re-usable as an import ref). */
75
+ externalId: string
76
+ title: string
77
+ url: string
78
+ /** Workflow status name, e.g. `In Progress` (may be empty). */
79
+ status: string
80
+ /** Short plain-text preview (may be empty). */
81
+ excerpt: string
82
+ }
@@ -1,18 +1,27 @@
1
- // Issue-tracker selection shapes, mirroring `@cat-factory/contracts` (tracker.ts).
2
- // A workspace designates one tracker — GitHub Issues or Jira — where the tech-debt
3
- // recurring pipeline files its ticket before implementation starts.
4
-
5
- export type TrackerKind = 'github' | 'jira'
6
-
7
- export interface TrackerSettings {
8
- /** The selected tracker, or null when none is configured. */
9
- tracker: TrackerKind | null
10
- /** Jira project key new tickets are filed under (e.g. 'ENG'); null unless Jira. */
11
- jiraProjectKey: string | null
12
- updatedAt: number
13
- }
14
-
15
- export interface PutTrackerSettingsInput {
16
- tracker: TrackerKind | null
17
- jiraProjectKey?: string | null
18
- }
1
+ // Issue-tracker selection shapes, mirroring `@cat-factory/contracts` (tracker.ts).
2
+ // A workspace designates one tracker — GitHub Issues or Jira — where the tech-debt
3
+ // recurring pipeline files its ticket before implementation starts.
4
+
5
+ export type TrackerKind = 'github' | 'jira'
6
+
7
+ export interface TrackerSettings {
8
+ /** The selected tracker, or null when none is configured. */
9
+ tracker: TrackerKind | null
10
+ /** Jira project key new tickets are filed under (e.g. 'ENG'); null unless Jira. */
11
+ jiraProjectKey: string | null
12
+ /** Writeback: comment on a task's linked issue when its PR opens. Per-task overridable. */
13
+ writebackCommentOnPrOpen: boolean
14
+ /** Writeback: comment + close a task's linked issue as resolved when its PR merges. */
15
+ writebackResolveOnMerge: boolean
16
+ updatedAt: number
17
+ }
18
+
19
+ export interface PutTrackerSettingsInput {
20
+ tracker: TrackerKind | null
21
+ jiraProjectKey?: string | null
22
+ writebackCommentOnPrOpen?: boolean
23
+ writebackResolveOnMerge?: boolean
24
+ }
25
+
26
+ /** Per-task writeback override; absent ⇒ inherit the workspace setting. */
27
+ export type WritebackOverride = 'on' | 'off'
@@ -1,128 +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('&lt;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
- })
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('&lt;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
+ })