@kikedealba/recap 0.9.1 → 0.10.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/README.md CHANGED
@@ -51,9 +51,11 @@ recap import llamada.mov --remote # video con dos pistas
51
51
 
52
52
  Los archivos en disco, los comandos, las opciones y el sobre `--json` son los
53
53
  mismos que los del `recap` en Swift, así que las dos versiones leen las mismas
54
- reuniones. Cuando [inkwell](https://github.com/KikeDeAlba/inkwell) está
55
- instalado y ya migró los documentos de bita, las páginas, las propuestas y el
56
- backlog se escriben con inkwell; si no, con `bita docs` y `bita backlog`.
54
+ reuniones. bita solo lleva el tiempo: las páginas, las propuestas, el backlog
55
+ y la nota de cada entrada viven en [inkwell](https://github.com/KikeDeAlba/inkwell),
56
+ y el volcado del tiempo a Jira lo hace [tally](https://github.com/KikeDeAlba/tally).
57
+ Sin inkwell la reunión se procesa igual: las etapas `proposals` y `wrapup` se
58
+ saltan con un aviso y la minuta se queda en la carpeta de la reunión.
57
59
 
58
60
  Para trabajar en el paquete: `pnpm install`, `pnpm test`, `pnpm typecheck` y
59
61
  `node src/bin/recap.ts <comando>`.
@@ -147,7 +149,7 @@ The repository is also a Claude Code plugin marketplace:
147
149
  | `/recap-status` | Shows whether a recording is running and how far processing got |
148
150
  | `/recap-list [meeting]` | Lists meetings or shows one meeting's minutes |
149
151
  | `/recap-summarize [meeting] [instructions]` | Rewrites the minutes inside the session, following extra instructions, and stores them with `recap save-summary` |
150
- | `/recap-ask [question]` | Answers the last question of the meeting being recorded, or the one given, from the bita pages and the project repositories |
152
+ | `/recap-ask [question]` | Answers the last question of the meeting being recorded, or the one given, from the inkwell pages and the project repositories |
151
153
  | `/recap-proposals [meeting]` | Reviews the documentation changes proposed by a meeting and accepts, edits or rejects them |
152
154
 
153
155
  The `recap` skill lets Claude answer questions such as "¿qué acordamos en la reunión de ayer?" from the stored minutes and transcripts. The commands call `recap`, so it must be on the `PATH` of the shell Claude Code runs.
@@ -160,7 +162,7 @@ While a meeting is being recorded, recap also transcribes it live:
160
162
  - A detached `recap live-worker` transcribes each chunk with `whisper-cli` (same model and VAD as the pipeline, 4 threads, the previous text as prompt), drops hallucinations and microphone echo, and appends `{startMs, endMs, channel, text}` lines to `live/transcript.jsonl`, with offsets from the start of the recording. It exits once the recording stops and the queue is empty.
161
163
  - The transcript after `stop` is still the source of truth; the live one is for answering during the meeting.
162
164
 
163
- `recap ask` answers a question with Claude Code (headless, streaming), reading only the bita pages and the repositories registered for the entry's project (`bita project repo ls`, bita 0.16 or later):
165
+ `recap ask` answers a question with Claude Code (headless, streaming), reading only the inkwell pages of the project (`inkwell page ls --project`) and the repositories registered for it in bita (`bita project repo ls`). Without inkwell it answers from the repositories alone:
164
166
 
165
167
  ```sh
166
168
  recap ask --active # the last question in the live transcript
@@ -191,9 +193,9 @@ With `live.autoAsk` on (the default), the live worker also looks for questions o
191
193
 
192
194
  ### Proposed documentation changes
193
195
 
194
- For meetings linked to a bita entry, the `proposals` stage (before `wrapup`) asks Claude Code (`Resources/proposals-prompt.md`) for the explicit, firm changes said about existing pages: the project pages and the pages linked to the entry, never the meeting's own page. Ideas, doubts and statements corrected later are left out, and the text follows bita's writing rule: nothing that reveals the conversation.
196
+ For meetings linked to a bita entry, when inkwell is installed, the `proposals` stage (before `wrapup`) asks Claude Code (`Resources/proposals-prompt.md`) for the explicit, firm changes said about existing pages: the project pages and the pages linked to the entry in inkwell (`inkwell page ls --entry`), never the meeting's own page. Ideas, doubts and statements corrected later are left out, and the text follows the documentation writing rule: nothing that reveals the conversation.
195
197
 
196
- Each change becomes a commit on the docs branch `proposal/meeting-<entry>` through `bita docs propose`, and is listed in `proposals.json` (its markdown in `proposals/<n>.md`). Nothing reaches `main`, or Confluence, until it is accepted:
198
+ Each change becomes a commit on the docs branch `proposal/meeting-<entry>` through `inkwell git propose`, and is listed in `proposals.json` (its markdown in `proposals/<n>.md`). Nothing reaches `main`, or Confluence, until it is accepted:
197
199
 
198
200
  ```sh
199
201
  recap proposals ls <meeting> --json # or --bita-entry <id>
@@ -202,41 +204,44 @@ recap proposals accept <meeting> <n> [--md edited.md]
202
204
  recap proposals reject <meeting> <n>
203
205
  ```
204
206
 
205
- `accept` runs `bita docs branch apply`; with `--md` it first proposes the edited text again. If the page changed since the proposal, the merge conflicts and the proposal turns `stale` (the command still succeeds). When no proposal is pending, the branch is dropped. A failure in this stage is recorded in `stages.proposals` and never stops the wrap-up. Turn it off with `recap config set live.proposals false`.
207
+ `accept` runs `inkwell branch apply` (and needs inkwell); with `--md` it first proposes the edited text again. If the page changed since the proposal, the merge conflicts and the proposal turns `stale` (the command still succeeds). When no proposal is pending, the branch is dropped. A failure in this stage is recorded in `stages.proposals` and never stops the wrap-up; without inkwell the stage is `skipped` with the reason in `stages.proposals.reason`. Turn it off with `recap config set live.proposals false`.
206
208
 
207
209
  ## bita integration
208
210
 
209
- With [bita](https://github.com/KikeDeAlba/bita-cli) 0.12 or later, a meeting timer records the meeting while it runs:
211
+ With [bita](https://github.com/KikeDeAlba/bita-cli), a meeting timer records the meeting while it runs:
210
212
 
211
213
  ```sh
212
214
  bita start "Planeación sprint 42" --kind remote-meeting # recap starts recording the screen, system audio and mic
213
215
  bita start "1:1 con Ana" --kind in-person-meeting # recap records the mic only
214
- bita stop # recap stops, processes, and writes the minutes into the entry
216
+ bita stop # recap stops, processes, and writes the minutes into the entry note in inkwell
215
217
  ```
216
218
 
217
- `recap setup` registers the hook in bita (`bita hooks add --on start,stop,cancel,amend --kind in-person-meeting,remote-meeting -- …/recap bita-hook`). It works the same whether the timer is started from the terminal, from Claude Code or from bita-desktop.
219
+ `recap setup` subscribes `recap bita-hook` to bita's `start`, `stop`, `cancel` and `amend` events through the kit registry. It works the same whether the timer is started from the terminal, from Claude Code or from bita-desktop.
218
220
 
219
221
  | bita event | recap |
220
222
  |---|---|
221
223
  | `start` of a meeting kind | starts recording in the matching mode, linked to the entry |
222
- | `stop` | stops, processes in the background and wraps the meeting up in bita (see below) |
224
+ | `stop` | stops, processes in the background and wraps the meeting up (see below) |
223
225
  | `cancel` | discards the recording |
224
226
  | `amend --kind <meeting kind>` on a running entry | starts recording |
225
227
  | `amend --kind none` while recording | stops without processing; the recording is kept |
228
+ | `amend` of the title or project only | updates the meeting's title and entry data; the recording is not touched |
226
229
 
227
230
  ### Wrap-up
228
231
 
229
- After the summary, the `wrapup` stage asks Claude Code (headless, `Resources/wrapup-prompt.md`) for a title, a project, a documentation page and backlog items, and applies them through the bita CLI:
232
+ After the summary, the `wrapup` stage asks Claude Code (headless, `Resources/wrapup-prompt.md`) for a title, a project, a documentation page and backlog items, and applies them: title and project through `bita amend`, everything else through inkwell (found in the kit registry by its capabilities):
230
233
 
231
234
  - the timer gets the meeting's real topic as its title when the current one is generic ("Reunión presencial", "Junta", …);
232
- - if the timer has no project, it gets one only when the conversation makes it clear and the name exists in `bita projects`; otherwise `wrapup.projectResolved` is false and `/bita-stop` asks;
233
- - a page is created with `bita docs page new --from-entry` in that project and written as formal documentation (no pending sections); if the timer already had a page, a `## Reunión <date>` section is added to it instead;
234
- - action items and open questions become `bita backlog` items on that page;
235
- - the minutes still land in the `## Reunión` section of the entry document.
235
+ - if the timer has no project, it gets one only when the conversation makes it clear and the name exists in `bita projects`; otherwise `wrapup.projectResolved` is false and `/recap-stop` asks;
236
+ - a page is created with `inkwell page new --from-entry` in that project and written as formal documentation (no pending sections); if the entry already has a page in inkwell (`inkwell page ls --entry`), a `## Reunión <date>` section is added to it instead;
237
+ - action items and findings become `inkwell backlog add` items on that page;
238
+ - the minutes land in the `Reunión` section of the entry note (`inkwell note save <entry> --section Reunión --md …`, capability `docs.entry-notes`).
239
+
240
+ Without inkwell the stage is `skipped` (reason and hint `npm i -g @kikedealba/inkwell && inkwell setup` in `stages.wrapup`) and the meeting is still `processed`; the minutes stay in `summary.md`. With an inkwell lacking `docs.entry-notes` the page and backlog are written and `process.log` notes that the minutes were not saved. Run `recap process <id> --from proposals` after installing it. Time goes to Jira later through tally, never from recap.
236
241
 
237
242
  `recap wait --bita-entry <id>` blocks until all of that is done and prints the result (`data.wrapup` with `--json`).
238
243
 
239
- The hook output goes to `hooks.log` beside the bita database. Minutes regenerated with `/recap-summarize` or `recap save-summary` are sent to bita again.
244
+ The hook output goes to `hooks.log` beside the bita database. Minutes regenerated with `/recap-summarize` or `recap save-summary` are saved in the entry note again when inkwell is installed.
240
245
 
241
246
  ## Pipeline
242
247
 
@@ -246,8 +251,8 @@ The hook output goes to `hooks.log` beside the bita database. Minutes regenerate
246
251
  | `transcribe` | per channel, then merged | mic | `transcript.json`, `transcript.md` |
247
252
  | `frames` | scene changes, at least 20 s apart, at most 40 | skipped | `frames/hh-mm-ss.jpg`, `frames.json` |
248
253
  | `summarize` | `claude -p` with transcript and frames | `claude -p` with transcript | `summary.md` |
249
- | `proposals` | only when linked to a bita entry and `live.proposals` is on | same | `proposals.json`, `proposals/<n>.md` and the docs branch `proposal/meeting-<entry>` |
250
- | `wrapup` | only when linked to a bita entry | same | entry title and project, a bita page, backlog items and the `## Reunión` section of the entry document |
254
+ | `proposals` | only when linked to a bita entry and `live.proposals` is on; skipped without inkwell | same | `proposals.json`, `proposals/<n>.md` and the docs branch `proposal/meeting-<entry>` |
255
+ | `wrapup` | only when linked to a bita entry; skipped without inkwell | same | entry title and project (bita), an inkwell page, backlog items and the `Reunión` section of the entry note |
251
256
 
252
257
  - Transcription runs locally with `whisper-cli`, `large-v3-turbo` and Silero VAD. Known whisper hallucinations on silence are dropped.
253
258
  - In remote meetings the microphone is labelled **Sala** and the call audio **Remotos**. When the microphone picks up the speakers, segments that repeat the call audio within a few seconds are removed as echo; headphones avoid the problem entirely.
@@ -10,7 +10,7 @@ Estás acompañando en vivo una reunión. Quien te consulta necesita una respues
10
10
 
11
11
  Responde solo con lo que encuentres en estas fuentes. Puedes leerlas con Read, Grep y Glob. Para el historial de un repositorio usa solo `git -C <ruta del repo> log`, `git -C <ruta del repo> show` o `git -C <ruta del repo> diff`, con la ruta exacta de la lista: un comando por llamada, sin `cd`, sin `&&` y sin tuberías; cualquier otra forma se rechaza.
12
12
 
13
- - Raíz de la documentación de bita: {{docsRoot}}
13
+ - Raíz de la documentación (inkwell): {{docsRoot}}
14
14
  - Páginas del proyecto (ruta relativa a la raíz):
15
15
  {{pages}}
16
16
  - Repositorios del proyecto:
@@ -1,4 +1,4 @@
1
- Vas a cerrar en bita el registro de una reunión ya transcrita y resumida. Con la minuta y la transcripción de abajo, decide cómo queda documentada.
1
+ Vas a cerrar el registro de una reunión ya transcrita y resumida. Con la minuta y la transcripción de abajo, decide cómo queda documentada.
2
2
 
3
3
  - Título actual del contador: «{{currentTitle}}»
4
4
  - Proyecto actual del contador: {{currentProject}}
@@ -5,8 +5,10 @@ allowed-tools: Bash(recap proposals:*), Bash(recap show:*), Read, Write
5
5
  ---
6
6
 
7
7
  Al cerrar una reunión ligada a bita, recap deja los cambios explícitos a
8
- páginas existentes como propuestas en la rama `proposal/meeting-<entrada>` de
9
- los docs. Nada llega a `main` sin aceptarse.
8
+ páginas existentes de inkwell como propuestas en la rama
9
+ `proposal/meeting-<entrada>` de sus docs. Nada llega a `main` sin aceptarse.
10
+ Aceptar o rechazar necesita inkwell instalado (`npm i -g @kikedealba/inkwell &&
11
+ inkwell setup`).
10
12
 
11
13
  1. La reunión es `$ARGUMENTS`, o `last` si viene vacío. Lista sus propuestas:
12
14
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Termina la grabación y deja la reunión documentada
3
3
  argument-hint: [--no-wait para no esperar el resultado]
4
- allowed-tools: Bash(recap stop:*), Bash(recap status:*), Bash(recap wait:*), Bash(recap show:*), Bash(bita stop:*), Bash(bita ls:*), Bash(bita amend:*), Bash(bita docs page move:*), Bash(bita backlog edit:*), Bash(inkwell page move:*), Bash(inkwell backlog edit:*)
4
+ allowed-tools: Bash(recap stop:*), Bash(recap status:*), Bash(recap wait:*), Bash(recap show:*), Bash(bita stop:*), Bash(bita ls:*), Bash(bita amend:*), Bash(inkwell page move:*), Bash(inkwell backlog edit:*)
5
5
  ---
6
6
 
7
7
  Detén la grabación activa y deja la reunión documentada.
@@ -12,23 +12,28 @@ Grabación actual:
12
12
 
13
13
  Si no hay grabación activa (`NOT_RECORDING`), dilo en una línea y termina.
14
14
 
15
- ## Si la grabación sigue a un contador de bita
15
+ ## Si la grabación sigue a un cronómetro de bita
16
16
 
17
- Cuando `data.active.bitaEntryId` trae un id, para **el contador**, no recap. Sin
18
- `--did` y sin escribir la página:
17
+ Cuando `data.active.bitaEntryId` trae un id, para **el cronómetro**, no recap,
18
+ sin escribir la página:
19
19
 
20
20
  ```
21
21
  bita stop <id> --json
22
22
  recap wait --bita-entry <id> --json
23
23
  ```
24
24
 
25
- El hook de bita detiene la grabación y recap hace lo demás:
25
+ El evento de bita detiene la grabación y recap hace lo demás:
26
26
 
27
27
  - transcribe y escribe la minuta;
28
- - le pone al contador un título real y, si no tenía, su proyecto;
29
- - crea o completa la página;
30
- - pasa los pendientes al backlog;
31
- - deja la minuta en la sección «Reunión» de la entrada.
28
+ - le pone al cronómetro un título real y, si no tenía, su proyecto (`bita amend`);
29
+ - crea o completa la página en inkwell;
30
+ - pasa los pendientes y hallazgos al backlog de inkwell;
31
+ - deja la minuta en la sección «Reunión» de la nota de la entrada (`inkwell note save`).
32
+
33
+ Sin inkwell, las etapas `proposals` y `wrapup` quedan en `skipped` con su
34
+ motivo (`stages.wrapup.reason`) y la minuta se queda en la carpeta de la
35
+ reunión; sugiere `npm i -g @kikedealba/inkwell && inkwell setup` y después
36
+ `recap process <id> --from proposals`.
32
37
 
33
38
  `recap wait` tarda de uno a cinco minutos en una reunión de una hora: córrelo con
34
39
  un timeout amplio (15 min). Si después de un minuto `recap status` sigue
@@ -46,9 +51,9 @@ recap wait --json
46
51
  Con el resultado de `recap wait` responde:
47
52
 
48
53
  - título y duración;
49
- - con bita: el proyecto, la página (`wrapup.pageId`) y cuántos pendientes y
50
- hallazgos quedaron en el backlog;
51
- - sin bita: los **Acuerdos** y **Pendientes** de la minuta, tal cual.
54
+ - con bita e inkwell: el proyecto, la página (`wrapup.pageId`) y cuántos
55
+ pendientes y hallazgos quedaron en el backlog;
56
+ - sin bita o sin inkwell: los **Acuerdos** y **Pendientes** de la minuta, tal cual.
52
57
 
53
58
  Si una etapa falló, muestra su error y sugiere `recap process <id>`: retoma
54
59
  desde la etapa que falló.
@@ -58,12 +63,11 @@ y, con la respuesta:
58
63
 
59
64
  ```
60
65
  bita amend <entryId> --project <X>
61
- bita docs page move <pageId> --project <X>
62
- bita backlog edit <CLAVE> --project <X> # una por cada valor de wrapup.backlogKeys
66
+ inkwell page move <pageId> --project <X>
67
+ inkwell backlog edit <CLAVE> --project <X> # una por cada valor de wrapup.backlogKeys
63
68
  ```
64
69
 
65
- Si inkwell está instalado, usa `inkwell page move <pageId> --project <X>` e
66
- `inkwell backlog edit <CLAVE> --project <X>` en lugar de los dos últimos.
70
+ El tiempo de la reunión se vuelca a Jira después con tally, no desde aquí.
67
71
 
68
72
  Con `--no-wait` en `$ARGUMENTS`, solo para y responde en una línea que el
69
73
  procesamiento sigue en segundo plano.
@@ -53,8 +53,6 @@ export class BitaClient {
53
53
  const args = [];
54
54
  if (this.target.databasePath)
55
55
  args.push('--db-path', this.target.databasePath);
56
- if (this.target.docsRoot)
57
- args.push('--docs-dir', this.target.docsRoot);
58
56
  return args;
59
57
  }
60
58
  async run(args) {
@@ -69,36 +67,49 @@ export class BitaClient {
69
67
  return parseResponse(result.stdout, result.stderr, result.status);
70
68
  }
71
69
  }
72
- export function inkwellArguments(args) {
73
- if (args[0] === 'backlog')
74
- return [...args];
75
- if (args[0] !== 'docs')
76
- return null;
77
- if (args[1] === 'propose')
78
- return ['git', 'propose', ...args.slice(2)];
79
- return args.slice(1);
80
- }
81
- let inkwellCache = null;
82
- export function resetInkwellCache() {
83
- inkwellCache = null;
84
- }
85
- async function discoverInkwell() {
70
+ export const INKWELL_HINT = 'npm i -g @kikedealba/inkwell && inkwell setup';
71
+ export const INKWELL_CAPABILITIES = {
72
+ pages: ['docs.page.read'],
73
+ wrapup: ['docs.page.read', 'docs.page.write', 'docs.backlog'],
74
+ proposals: ['docs.page.read', 'docs.propose'],
75
+ notes: ['docs.entry-notes'],
76
+ };
77
+ export async function lookupInkwell(capabilities = []) {
86
78
  if (process.env['RECAP_NO_INKWELL'] === '1')
87
- return null;
88
- const tool = await findTool('inkwell', { capability: 'docs.page.write' }).catch(() => null);
79
+ return { client: null, reason: 'inkwell is turned off (RECAP_NO_INKWELL=1)' };
80
+ const tool = await findTool('inkwell').catch(() => null);
89
81
  if (!tool)
90
- return null;
91
- try {
92
- const { envelope } = await invokeTool(tool, ['migrate', 'status'], { env: { ...process.env, ...QUIET_ENV }, timeoutMs: 20_000 });
93
- return envelope.ok && envelope.data?.migrated === true ? tool : null;
94
- }
95
- catch {
96
- return null;
82
+ return { client: null, reason: 'inkwell is not installed' };
83
+ const missing = capabilities.filter((capability) => !tool.manifest.capabilities.includes(capability));
84
+ if (missing.length > 0)
85
+ return { client: null, reason: `inkwell ${tool.manifest.version} lacks ${missing.join(', ')}` };
86
+ return { client: new InkwellClient(tool) };
87
+ }
88
+ export async function findInkwell(capabilities = []) {
89
+ return (await lookupInkwell(capabilities)).client;
90
+ }
91
+ export async function requireInkwell(capabilities, purpose) {
92
+ const found = await lookupInkwell(capabilities);
93
+ if (found.client)
94
+ return found.client;
95
+ throw new RecapError('DEPENDENCY_MISSING', `${purpose} needs inkwell: ${found.reason}`, { hint: INKWELL_HINT });
96
+ }
97
+ export class StageSkipped extends RecapError {
98
+ constructor(message, hint) {
99
+ super('STAGE_SKIPPED', message, hint === undefined ? {} : { hint });
97
100
  }
98
101
  }
99
- export function findInkwell() {
100
- inkwellCache ??= discoverInkwell();
101
- return inkwellCache;
102
+ export async function inkwellForStage(capabilities) {
103
+ const found = await lookupInkwell(capabilities);
104
+ if (found.client)
105
+ return found.client;
106
+ throw new StageSkipped(found.reason, INKWELL_HINT);
107
+ }
108
+ export async function callInkwell(inkwell, args) {
109
+ const response = await inkwell.invoke(args);
110
+ if (!response.ok)
111
+ throw new RecapError('INKWELL_FAILED', `inkwell ${args.slice(0, 2).join(' ')} failed: ${response.errorMessage ?? 'no reason given'}`);
112
+ return response.data;
102
113
  }
103
114
  export class InkwellClient {
104
115
  tool;
@@ -121,32 +132,9 @@ export class InkwellClient {
121
132
  }
122
133
  }
123
134
  }
124
- export class DocsRouter {
125
- bita;
126
- inkwell;
127
- constructor(bita, inkwell) {
128
- this.bita = bita;
129
- this.inkwell = inkwell;
130
- }
131
- async invoke(args) {
132
- const mapped = this.inkwell ? inkwellArguments(args) : null;
133
- if (mapped && this.inkwell)
134
- return this.inkwell.invoke(mapped);
135
- if (this.bita)
136
- return this.bita.invoke(args);
137
- return { ok: false, errorCode: 'DEPENDENCY_MISSING', errorMessage: 'bita not found' };
138
- }
139
- }
140
135
  export function meetingTarget(meeting) {
141
136
  return { databasePath: meeting.bitaDatabasePath, docsRoot: meeting.bitaDocsRoot };
142
137
  }
143
- export async function docsClient(config, target) {
144
- const bita = await BitaClient.create(config, target);
145
- const inkwell = await findInkwell();
146
- if (!bita && !inkwell)
147
- return null;
148
- return new DocsRouter(bita, inkwell ? new InkwellClient(inkwell) : null);
149
- }
150
138
  export async function requireBita(config, target) {
151
139
  const client = await BitaClient.create(config, target);
152
140
  if (!client)
package/dist/bita/hook.js CHANGED
@@ -14,6 +14,8 @@ export function decodeHookEvent(text) {
14
14
  const entry = value['entry'];
15
15
  if (typeof entry['id'] !== 'number' || !Number.isInteger(entry['id']) || typeof entry['description'] !== 'string')
16
16
  throw new Error('the entry has no id or description');
17
+ const reportsEveryChange = 'previousTitle' in value || 'previousProjectId' in value;
18
+ const kindChanged = !reportsEveryChange || (optionalString(value['previousKind']) ?? null) !== (optionalString(entry['kind']) ?? null);
17
19
  const pageIds = Array.isArray(value['pageIds']) ? value['pageIds'].filter((id) => typeof id === 'number' && Number.isInteger(id)) : undefined;
18
20
  return {
19
21
  event: value['event'],
@@ -25,9 +27,11 @@ export function decodeHookEvent(text) {
25
27
  projectName: optionalString(entry['projectName']),
26
28
  },
27
29
  previousKind: optionalString(value['previousKind']),
30
+ previousTitle: optionalString(value['previousTitle']),
28
31
  databasePath: optionalString(value['databasePath']),
29
32
  docsRoot: optionalString(value['docsRoot']),
30
33
  pageIds,
34
+ kindChanged,
31
35
  };
32
36
  }
33
37
  export function eventSnapshot(event) {
@@ -65,6 +69,8 @@ export function planHook(event, activeEntryId) {
65
69
  case 'cancel':
66
70
  return { type: 'discard' };
67
71
  case 'amend': {
72
+ if (!event.kindChanged)
73
+ return { type: 'refresh' };
68
74
  const previous = previousMode(event);
69
75
  if (mode && previous === undefined) {
70
76
  if (event.entry.running === false)
@@ -7,10 +7,10 @@ import { withDirectoryLockAsync } from "../core/lock.js";
7
7
  import { home } from "../core/paths.js";
8
8
  import { fold, prefix, replaceAll, trimmed } from "../core/text.js";
9
9
  import { RecapError, errorMessage } from "../errors.js";
10
- import { loadProjectContext, meetingProject } from "../live/context.js";
10
+ import { entryPages, loadProjectContext, meetingProject } from "../live/context.js";
11
11
  import { resultText, runClaude } from "../pipeline/claude.js";
12
12
  import { loadResource } from "../pipeline/resources.js";
13
- import { callBita } from "./client.js";
13
+ import { callInkwell } from "./client.js";
14
14
  import { meetingDay } from "./wrapup.js";
15
15
  export const REVEALING_PHRASES = [
16
16
  'se acordo',
@@ -190,45 +190,42 @@ export function saveProposals(file, dir) {
190
190
  discarded: file.discarded.map((item) => ({ index: item.index, pageId: item.pageId, reason: item.reason })),
191
191
  }));
192
192
  }
193
- export async function propose(bita, branch, pageId, section, file, reason, entryId) {
194
- const args = ['docs', 'propose', '--branch', branch, String(pageId), '--md', file];
193
+ export async function propose(inkwell, branch, pageId, section, file, reason, entryId) {
194
+ const args = ['git', 'propose', '--branch', branch, String(pageId), '--md', file];
195
195
  if (section !== null)
196
196
  args.push('--section', section);
197
197
  args.push('--reason', reason, '--source', `meeting:${entryId}`);
198
- const data = await callBita(bita, args);
198
+ const data = await callInkwell(inkwell, args);
199
199
  const sha = isRecord(data) && typeof data['sha'] === 'string' ? data['sha'] : '';
200
200
  if (sha.length === 0)
201
- throw new RecapError('BITA_FAILED', 'bita docs propose did not return the commit sha');
201
+ throw new RecapError('INKWELL_FAILED', 'inkwell git propose did not return the commit sha');
202
202
  return sha;
203
203
  }
204
- export function ownPageId(meeting) {
205
- return meeting.wrapup?.pageId ?? meeting.bitaEntry?.pageIds[0];
204
+ export function ownPageId(meeting, linked) {
205
+ return meeting.wrapup?.pageId ?? linked[0]?.pageId;
206
206
  }
207
207
  export class ProposalGenerator {
208
208
  config;
209
- bita;
209
+ inkwell;
210
210
  log;
211
211
  claude;
212
- constructor(config, bita, log, claude) {
212
+ constructor(config, inkwell, log, claude) {
213
213
  this.config = config;
214
- this.bita = bita;
214
+ this.inkwell = inkwell;
215
215
  this.log = log;
216
216
  this.claude =
217
217
  claude ??
218
218
  (async (prompt, dirs) => resultText(await runClaude({ prompt, cwd: dirs[dirs.length - 1] ?? home(), config, tools: ['Read Grep Glob'], addDirs: dirs, model: config.summaryModel })));
219
219
  }
220
- async candidates(meeting, context) {
221
- const own = ownPageId(meeting);
220
+ candidates(meeting, context, linked) {
221
+ const own = ownPageId(meeting, linked);
222
222
  const pages = [...context.pages];
223
223
  const known = new Set(pages.map((page) => page.pageId));
224
- for (const pageId of meeting.bitaEntry?.pageIds ?? []) {
225
- if (known.has(pageId) || pageId === own)
224
+ for (const page of linked) {
225
+ if (known.has(page.pageId) || page.pageId === own)
226
226
  continue;
227
- const response = await this.bita.invoke(['docs', 'page', 'show', String(pageId), '--no-markdown']).catch(() => null);
228
- if (!response?.ok || !isRecord(response.data) || typeof response.data['title'] !== 'string')
229
- continue;
230
- pages.push({ pageId, title: response.data['title'], relPath: typeof response.data['relPath'] === 'string' ? response.data['relPath'] : '', depth: 0 });
231
- known.add(pageId);
227
+ pages.push({ ...page, depth: 0 });
228
+ known.add(page.pageId);
232
229
  }
233
230
  return pages.filter((page) => page.pageId !== own);
234
231
  }
@@ -244,10 +241,11 @@ export class ProposalGenerator {
244
241
  return existing;
245
242
  }
246
243
  if (existing.proposals.length > 0)
247
- await this.bita.invoke(['docs', 'branch', 'drop', branch]).catch(() => null);
244
+ await this.inkwell.invoke(['branch', 'drop', branch]).catch(() => null);
248
245
  }
249
- const context = await loadProjectContext(await meetingProject(meeting, this.bita), meeting.bitaDocsRoot, this.bita);
250
- const pages = await this.candidates(meeting, context);
246
+ const linked = await entryPages(this.inkwell, entryId);
247
+ const context = await loadProjectContext(await meetingProject(meeting, this.inkwell, linked), { inkwell: this.inkwell, bita: null });
248
+ const pages = this.candidates(meeting, context, linked);
251
249
  const file = { entryId, branch, generatedAt: isoNow(), branchDropped: false, proposals: [], discarded: [] };
252
250
  if (pages.length === 0 || context.docsRoot === null) {
253
251
  this.log('proposals: no candidate pages');
@@ -276,7 +274,7 @@ export class ProposalGenerator {
276
274
  const markdownFile = proposalStore.markdownFile(dir, n);
277
275
  writeAtomic(markdownFile, `${draft.markdown}\n`);
278
276
  try {
279
- const sha = await propose(this.bita, branch, draft.pageId, draft.section, markdownFile, draft.title, entryId);
277
+ const sha = await propose(this.inkwell, branch, draft.pageId, draft.section, markdownFile, draft.title, entryId);
280
278
  file.proposals.push({
281
279
  n,
282
280
  pageId: draft.pageId,
@@ -323,10 +321,10 @@ export class ProposalGenerator {
323
321
  }
324
322
  export class ProposalReview {
325
323
  dir;
326
- bita;
327
- constructor(dir, bita) {
324
+ inkwell;
325
+ constructor(dir, inkwell) {
328
326
  this.dir = dir;
329
- this.bita = bita;
327
+ this.inkwell = inkwell;
330
328
  }
331
329
  list() {
332
330
  return loadProposals(this.dir)?.proposals ?? [];
@@ -341,7 +339,7 @@ export class ProposalReview {
341
339
  return found;
342
340
  }
343
341
  async diff(proposal) {
344
- const response = await this.bita.invoke(['docs', 'branch', 'diff', proposal.branch, '--commit', proposal.sha]).catch(() => null);
342
+ const response = await this.inkwell.invoke(['branch', 'diff', proposal.branch, '--commit', proposal.sha]).catch(() => null);
345
343
  return response?.ok ? response.data : null;
346
344
  }
347
345
  load() {
@@ -361,7 +359,7 @@ export class ProposalReview {
361
359
  if (index !== -1)
362
360
  file.proposals[index] = proposal;
363
361
  if (!file.branchDropped && !file.proposals.some((item) => item.status === 'pending')) {
364
- const response = await this.bita.invoke(['docs', 'branch', 'drop', file.branch]).catch(() => null);
362
+ const response = await this.inkwell.invoke(['branch', 'drop', file.branch]).catch(() => null);
365
363
  file.branchDropped = response?.ok === true;
366
364
  }
367
365
  saveProposals(file, this.dir);
@@ -384,10 +382,10 @@ export class ProposalReview {
384
382
  writeAtomic(target, text);
385
383
  }
386
384
  if (editedMarkdown !== undefined || file.branchDropped) {
387
- proposal.sha = await propose(this.bita, proposal.branch, proposal.pageId, proposal.section, target, proposal.title, file.entryId);
385
+ proposal.sha = await propose(this.inkwell, proposal.branch, proposal.pageId, proposal.section, target, proposal.title, file.entryId);
388
386
  file.branchDropped = false;
389
387
  }
390
- const response = await this.bita.invoke(['docs', 'branch', 'apply', proposal.branch, '--commit', proposal.sha]);
388
+ const response = await this.inkwell.invoke(['branch', 'apply', proposal.branch, '--commit', proposal.sha]);
391
389
  if (response.ok) {
392
390
  proposal.status = 'accepted';
393
391
  const data = isRecord(response.data) ? response.data : {};
@@ -397,7 +395,7 @@ export class ProposalReview {
397
395
  proposal.status = 'stale';
398
396
  }
399
397
  else {
400
- throw new RecapError('BITA_FAILED', `bita docs branch apply failed: ${response.errorMessage ?? 'no reason given'}`);
398
+ throw new RecapError('INKWELL_FAILED', `inkwell branch apply failed: ${response.errorMessage ?? 'no reason given'}`);
401
399
  }
402
400
  proposal.updatedAt = isoNow();
403
401
  await this.store(proposal, file);
@@ -3,11 +3,12 @@ import { dayString, parseDate } from "../core/dates.js";
3
3
  import { readText, writeAtomic } from "../core/fsutil.js";
4
4
  import { extractBetween, isRecord, swiftDefault } from "../core/json.js";
5
5
  import { durationSeconds, emptyWrapup, updateMeeting } from "../core/meeting.js";
6
- import { fold, formatDuration, prefix, replaceAll, suffix, trimmed } from "../core/text.js";
6
+ import { fold, formatDuration, prefix, replaceAll, trimmed } from "../core/text.js";
7
7
  import { RecapError } from "../errors.js";
8
8
  import { resultText, runSummaryClaude } from "../pipeline/claude.js";
9
9
  import { loadResource } from "../pipeline/resources.js";
10
- import { callBita, docsClient, meetingTarget, requireBita } from "./client.js";
10
+ import { INKWELL_CAPABILITIES, INKWELL_HINT, StageSkipped, callBita, callInkwell, lookupInkwell, meetingTarget, requireBita } from "./client.js";
11
+ import { entryPages } from "../live/context.js";
11
12
  export const FORBIDDEN_SECTIONS = ['pendiente', 'proximos pasos', 'hallazgo', 'preguntas abiertas', 'lo que falta'];
12
13
  export function stripForbiddenSections(markdown) {
13
14
  const kept = [];
@@ -127,20 +128,30 @@ export function noteBody(summary, meeting, dir) {
127
128
  lines.push('', `Archivos de la reunión: \`${dir}\` (\`summary.md\`, \`transcript.md\`${meeting.mode === 'remote' ? ', `frames/`' : ''}).`);
128
129
  return `${lines.join('\n')}\n`;
129
130
  }
130
- export async function saveMinutes(meeting, dir, config, client) {
131
+ export async function saveMinutes(meeting, dir, notes, log) {
131
132
  const entryId = meeting.bitaEntryId;
132
133
  if (entryId === undefined)
133
- return;
134
- const bita = client ?? (await requireBita(config, meetingTarget(meeting)));
134
+ return { saved: false, reason: 'the meeting has no bita entry' };
135
135
  const summary = readText(path.join(dir, 'summary.md'));
136
136
  if (summary === null)
137
- throw new RecapError('NO_SUMMARY', `"${meeting.title}" has no summary to send to bita`);
138
- const note = path.join(dir, 'bita-note.md');
137
+ throw new RecapError('NO_SUMMARY', `"${meeting.title}" has no summary to save in the entry note`);
138
+ const note = path.join(dir, 'entry-note.md');
139
139
  writeAtomic(note, noteBody(summary, meeting, dir));
140
- const result = await bita.run(['note', 'save', String(entryId), '--note-md', note, '--section', NOTE_SECTION, '--json', ...bita.targetArgs()]);
141
- if (result.status !== 0) {
142
- throw new RecapError('BITA_FAILED', `bita note save ${entryId} failed: ${suffix(trimmed(result.stdout + result.stderr), 400)}`);
140
+ let client = notes;
141
+ let reason = 'no inkwell with docs.entry-notes was given';
142
+ if (client === undefined) {
143
+ const found = await lookupInkwell(INKWELL_CAPABILITIES.notes);
144
+ client = found.client;
145
+ if (found.reason !== undefined)
146
+ reason = found.reason;
147
+ }
148
+ if (!client) {
149
+ log?.(`minutes: not saved in the note of entry #${entryId}, ${reason}; they stay in ${dir} (${INKWELL_HINT})`);
150
+ return { saved: false, reason };
143
151
  }
152
+ await callInkwell(client, ['note', 'save', String(entryId), '--section', NOTE_SECTION, '--md', note]);
153
+ log?.(`minutes: saved in the note of entry #${entryId}`);
154
+ return { saved: true };
144
155
  }
145
156
  export function meetingDay(meeting) {
146
157
  return dayString(parseDate(meeting.startedAt) ?? parseDate(meeting.createdAt) ?? new Date());
@@ -155,7 +166,7 @@ export function encodeWrapupPlan(plan) {
155
166
  });
156
167
  }
157
168
  async function pageText(docs, pageId) {
158
- const page = await callBita(docs, ['docs', 'page', 'show', String(pageId)]);
169
+ const page = await callInkwell(docs, ['page', 'show', String(pageId)]);
159
170
  const doc = isRecord(page) && isRecord(page['doc']) ? page['doc'] : undefined;
160
171
  const file = typeof doc?.['path'] === 'string' ? doc['path'] : undefined;
161
172
  return file ? readText(file) : null;
@@ -182,8 +193,16 @@ export async function runWrapup(meeting, dir, config, dependencies) {
182
193
  const entryId = meeting.bitaEntryId;
183
194
  if (entryId === undefined)
184
195
  return;
196
+ let docs = dependencies?.docs;
197
+ if (!docs) {
198
+ const found = await lookupInkwell(INKWELL_CAPABILITIES.wrapup);
199
+ if (!found.client) {
200
+ await saveMinutes(meeting, dir, undefined, dependencies?.log).catch((error) => dependencies?.log?.(`minutes: ${String(error)}`));
201
+ throw new StageSkipped(found.reason, INKWELL_HINT);
202
+ }
203
+ docs = found.client;
204
+ }
185
205
  const bita = dependencies?.bita ?? (await requireBita(config, meetingTarget(meeting)));
186
- const docs = dependencies?.docs ?? (await docsClient(config, meetingTarget(meeting))) ?? bita;
187
206
  const claude = dependencies?.claude ?? (async (prompt) => resultText(await runSummaryClaude(prompt, dir, config)));
188
207
  const snapshot = meeting.bitaEntry ?? { title: meeting.title, pageIds: [] };
189
208
  const wrapup = { ...emptyWrapup(), ...(meeting.wrapup ?? {}) };
@@ -192,7 +211,7 @@ export async function runWrapup(meeting, dir, config, dependencies) {
192
211
  .filter(isRecord)
193
212
  .filter((project) => project['active'] !== false)
194
213
  .flatMap((project) => (typeof project['name'] === 'string' ? [project['name']] : []));
195
- const existingPageId = wrapup.pageId ?? snapshot.pageIds[0];
214
+ const existingPageId = wrapup.pageId ?? (await entryPages(docs, entryId, true))[0]?.pageId;
196
215
  const existingPage = existingPageId === undefined ? null : await pageText(docs, existingPageId);
197
216
  const plan = await planWrapup(meeting, dir, snapshot, projects, existingPage, claude);
198
217
  writeAtomic(path.join(dir, 'wrapup.json'), encodeWrapupPlan(plan));
@@ -209,23 +228,23 @@ export async function runWrapup(meeting, dir, config, dependencies) {
209
228
  await callBita(bita, ['amend', String(entryId), '--project', choice.apply]);
210
229
  wrapup.project = choice.apply ?? snapshot.projectName;
211
230
  wrapup.projectResolved = choice.resolved;
212
- const pageFile = path.join(dir, 'bita-page.md');
231
+ const pageFile = path.join(dir, 'wrapup-page.md');
213
232
  if (existingPageId !== undefined) {
214
233
  writeAtomic(pageFile, demoteHeadings(plan.pageMarkdown));
215
- await callBita(docs, ['docs', 'page', 'write', String(existingPageId), '--md', pageFile, '--section', `Reunión ${meetingDay(meeting)}`]);
234
+ await callInkwell(docs, ['page', 'write', String(existingPageId), '--md', pageFile, '--section', `Reunión ${meetingDay(meeting)}`]);
216
235
  wrapup.pageId = existingPageId;
217
236
  }
218
237
  else {
219
238
  writeAtomic(pageFile, plan.pageMarkdown);
220
- const args = ['docs', 'page', 'new', plan.pageTitle, '--from-entry', String(entryId)];
239
+ const args = ['page', 'new', plan.pageTitle, '--from-entry', String(entryId)];
221
240
  if (wrapup.project)
222
241
  args.push('--project', wrapup.project);
223
- const created = await callBita(docs, args);
242
+ const created = await callInkwell(docs, args);
224
243
  const page = isRecord(created) && isRecord(created['page']) ? created['page'] : undefined;
225
244
  const pageId = typeof page?.['pageId'] === 'number' ? page['pageId'] : undefined;
226
245
  if (pageId === undefined)
227
- throw new RecapError('BITA_FAILED', 'bita docs page new did not return the page id');
228
- await callBita(docs, ['docs', 'page', 'write', String(pageId), '--md', pageFile]);
246
+ throw new RecapError('INKWELL_FAILED', 'inkwell page new did not return the page id');
247
+ await callInkwell(docs, ['page', 'write', String(pageId), '--md', pageFile]);
229
248
  wrapup.pageId = pageId;
230
249
  wrapup.pageCreated = true;
231
250
  }
@@ -237,17 +256,17 @@ export async function runWrapup(meeting, dir, config, dependencies) {
237
256
  continue;
238
257
  const args = ['backlog', 'add', '--kind', item.kind, '--title', item.title, '--page', String(wrapup.pageId ?? 0)];
239
258
  if (item.body) {
240
- const bodyFile = path.join(dir, 'bita-backlog-item.md');
259
+ const bodyFile = path.join(dir, 'wrapup-backlog-item.md');
241
260
  writeAtomic(bodyFile, item.body);
242
261
  args.push('--md', bodyFile);
243
262
  }
244
- const added = await callBita(docs, args);
263
+ const added = await callInkwell(docs, args);
245
264
  wrapup.backlogKeys[item.title] = isRecord(added) && typeof added['key'] === 'string' ? added['key'] : '?';
246
265
  updateMeeting(dir, (current) => {
247
266
  current.wrapup = { ...wrapup, backlogKeys: { ...wrapup.backlogKeys } };
248
267
  });
249
268
  }
250
- await saveMinutes(meeting, dir, config, bita);
269
+ await saveMinutes(meeting, dir, dependencies?.notes, dependencies?.log);
251
270
  updateMeeting(dir, (current) => {
252
271
  current.wrapup = { ...wrapup, backlogKeys: { ...wrapup.backlogKeys } };
253
272
  });
@@ -5,10 +5,9 @@ import { MeetingStore, loadMeeting, resolveTarget } from "../../core/meeting.js"
5
5
  import { signalNumber, signalTree } from "../../core/proc.js";
6
6
  import { trimmed } from "../../core/text.js";
7
7
  import { RecapError, asRecapError, usageError } from "../../errors.js";
8
- import { BitaClient, docsClient, meetingTarget } from "../../bita/client.js";
9
8
  import { runAsk } from "../../live/ask.js";
10
9
  import { isValidAskId, makeAskId, performAsk, removeBoard } from "../../live/asking.js";
11
- import { loadProjectContext, meetingProject, meetingProjectName } from "../../live/context.js";
10
+ import { contextSources, loadProjectContext, meetingContext, meetingProjectName } from "../../live/context.js";
12
11
  import { jsonLine } from "../../live/files.js";
13
12
  import { encodeAnswer, encodeAskEvent } from "../../live/stream.js";
14
13
  import { parseArgs } from "../args.js";
@@ -77,8 +76,7 @@ async function answer(args, emit) {
77
76
  let childPid = null;
78
77
  installCleanup(found.dir, id, () => childPid);
79
78
  return performAsk({ dir: found.dir, id, question, auto: args.has('auto'), now, questionMs, channel }, async () => {
80
- const docs = await docsClient(config, meetingTarget(found.meeting));
81
- const context = await loadProjectContext(await meetingProject(found.meeting, docs), found.meeting.bitaDocsRoot, docs);
79
+ const context = await meetingContext(found.meeting, config);
82
80
  return runAsk(config, { meeting: found.meeting, dir: found.dir, question, windowSeconds: args.int('window') ?? 180, now, auto: args.has('auto'), questionMs, channel, askId: id }, context, emit, (pid) => {
83
81
  childPid = pid;
84
82
  });
@@ -105,9 +103,9 @@ export async function askCommand(argv) {
105
103
  if (args.has('sources')) {
106
104
  const config = loadConfig();
107
105
  const found = args.has('active') || args.value('meeting') !== undefined || args.value('bita-entry') !== undefined ? target(args, config) : null;
108
- const docs = found ? await docsClient(config, meetingTarget(found.meeting)) : await docsClient(config, {});
106
+ const sources = await contextSources(config, found?.meeting ?? null);
109
107
  const name = args.value('project') ?? (found ? meetingProjectName(found.meeting) : null);
110
- const context = await loadProjectContext(name, found?.meeting.bitaDocsRoot, docs ?? (await BitaClient.create(config, {})));
108
+ const context = await loadProjectContext(name, sources);
111
109
  const lines = [`Project: ${context.project ?? '(none)'}`, `Docs: ${context.docsRoot ?? '(unknown)'}`, ...context.repos.map((repo) => `Repo: ${repo.slug} ${repo.path}${repo.exists ? '' : ' (missing)'}`)];
112
110
  return { data: { project: context.project ?? undefined, docsRoot: context.docsRoot ?? undefined, repos: context.repos }, text: lines.join('\n') };
113
111
  }
@@ -62,6 +62,21 @@ export async function handleHookEvent(event) {
62
62
  log(`discarded ${record.dir}`);
63
63
  break;
64
64
  }
65
+ case 'refresh': {
66
+ const found = new MeetingStore(loadConfig()).find(event.entry.id);
67
+ if (!found) {
68
+ log(`nothing to do: no meeting for entry #${event.entry.id}`);
69
+ break;
70
+ }
71
+ updateMeeting(found.dir, (meeting) => {
72
+ meeting.bitaEntry = { ...eventSnapshot(event), pageIds: meeting.bitaEntry?.pageIds ?? [] };
73
+ const renamed = event.previousTitle !== undefined && event.previousTitle !== event.entry.description;
74
+ if (renamed && event.entry.description.trim().length > 0)
75
+ meeting.title = event.entry.description;
76
+ });
77
+ log(`updated ${found.dir}`);
78
+ break;
79
+ }
65
80
  case 'ignore':
66
81
  log(`nothing to do: ${action.reason}`);
67
82
  break;
@@ -3,7 +3,7 @@ import { expandTilde } from "../../core/paths.js";
3
3
  import { readText } from "../../core/fsutil.js";
4
4
  import { MeetingStore, resolveTarget } from "../../core/meeting.js";
5
5
  import { RecapError, usageError } from "../../errors.js";
6
- import { docsClient, meetingTarget } from "../../bita/client.js";
6
+ import { INKWELL_CAPABILITIES, INKWELL_HINT, findInkwell, requireInkwell } from "../../bita/client.js";
7
7
  import { ProposalReview, loadProposals, proposalJson } from "../../bita/proposals.js";
8
8
  import { parseArgs } from "../args.js";
9
9
  import { output } from "../output.js";
@@ -21,13 +21,11 @@ function number(args) {
21
21
  throw new RecapError('PROPOSAL_REQUIRED', 'Pass the proposal number');
22
22
  return Number(raw);
23
23
  }
24
- const noBita = { invoke: async () => ({ ok: false, errorCode: 'DEPENDENCY_MISSING', errorMessage: 'bita not found' }) };
24
+ const noInkwell = { invoke: async () => ({ ok: false, errorCode: 'DEPENDENCY_MISSING', errorMessage: `inkwell not found (${INKWELL_HINT})` }) };
25
25
  async function review(args, config, required) {
26
26
  const found = meeting(args, config);
27
- const docs = await docsClient(config, meetingTarget(found.meeting));
28
- if (!docs && required)
29
- throw new RecapError('DEPENDENCY_MISSING', 'bita not found. Install it with `npm install -g @kikedealba/bita`');
30
- return new ProposalReview(found.dir, docs ?? noBita);
27
+ const inkwell = required ? await requireInkwell(INKWELL_CAPABILITIES.proposals, 'Reviewing proposals') : await findInkwell(INKWELL_CAPABILITIES.proposals);
28
+ return new ProposalReview(found.dir, inkwell ?? noInkwell);
31
29
  }
32
30
  export async function proposalsCommand(argv) {
33
31
  const [sub, ...rest] = argv;
@@ -10,6 +10,7 @@ import { processInBackground } from "../../core/self.js";
10
10
  import { formatDuration, padEnd } from "../../core/text.js";
11
11
  import { RecapError, usageError } from "../../errors.js";
12
12
  import { saveMinutes } from "../../bita/wrapup.js";
13
+ import { INKWELL_HINT } from "../../bita/client.js";
13
14
  import { Pipeline } from "../../pipeline/pipeline.js";
14
15
  import { STAGES, allStagesSatisfied, isStage } from "../../pipeline/stages.js";
15
16
  import { saveSummary, summaryPromptFor } from "../../pipeline/summary.js";
@@ -106,14 +107,17 @@ export async function saveSummaryCommand(argv) {
106
107
  throw new RecapError('FILE_UNREADABLE', `Cannot read ${file}: ${error.message}`);
107
108
  }
108
109
  saveSummary(body, meeting, dir);
109
- if (meeting.bitaEntryId !== undefined)
110
- await saveMinutes(meeting, dir, config);
110
+ let minutes = null;
111
+ if (meeting.bitaEntryId !== undefined) {
112
+ const result = await saveMinutes(meeting, dir);
113
+ minutes = result.saved ? `Minutes saved in the note of entry #${meeting.bitaEntryId}` : `Minutes not saved in the entry note: ${result.reason ?? 'no reason given'} (${INKWELL_HINT})`;
114
+ }
111
115
  const updated = updateMeeting(dir, (current) => {
112
116
  current.stages['summarize'] = { status: 'done', updatedAt: isoNow() };
113
117
  if (allStagesSatisfied({ ...meeting, stages: current.stages }))
114
118
  current.status = 'processed';
115
119
  });
116
- return { data: meetingRecord(updated, dir), text: path.join(dir, 'summary.md') };
120
+ return { data: meetingRecord(updated, dir), text: minutes === null ? path.join(dir, 'summary.md') : `${path.join(dir, 'summary.md')}\n${minutes}` };
117
121
  });
118
122
  }
119
123
  export function failedStage(meeting) {
@@ -131,6 +135,10 @@ export function describeWait(meeting, dir) {
131
135
  const failed = failedStage(meeting);
132
136
  if (failed)
133
137
  lines.push(`Failed at ${failed[0]}: ${failed[1].error ?? 'no reason given'}`);
138
+ for (const [stage, state] of Object.entries(meeting.stages)) {
139
+ if (state.status === 'skipped' && state.reason !== undefined)
140
+ lines.push(`Skipped ${stage}: ${state.reason}${state.hint !== undefined ? ` (${state.hint})` : ''}`);
141
+ }
134
142
  const wrapup = meeting.wrapup;
135
143
  if (wrapup) {
136
144
  lines.push(`Title : ${wrapup.title ?? meeting.title}`);
@@ -2,6 +2,8 @@ import path from 'node:path';
2
2
  import { isDirectory } from "../core/fsutil.js";
3
3
  import { isRecord } from "../core/json.js";
4
4
  import { trimmed } from "../core/text.js";
5
+ import { BitaClient, INKWELL_CAPABILITIES, findInkwell, meetingTarget } from "../bita/client.js";
6
+ import { RecapError } from "../errors.js";
5
7
  export function emptyContext(overrides = {}) {
6
8
  return { project: null, docsRoot: null, pages: [], repos: [], ...overrides };
7
9
  }
@@ -24,6 +26,7 @@ export function parsePages(data) {
24
26
  title,
25
27
  relPath: typeof item['relPath'] === 'string' ? item['relPath'] : '',
26
28
  depth: typeof item['depth'] === 'number' && Number.isInteger(item['depth']) ? item['depth'] : depth,
29
+ ...(typeof item['projectName'] === 'string' ? { projectName: item['projectName'] } : {}),
27
30
  });
28
31
  if (Array.isArray(item['children']))
29
32
  walk(item['children'], depth + 1);
@@ -52,39 +55,63 @@ export function parseRepos(data) {
52
55
  function metaRoot(meta) {
53
56
  return typeof meta?.['root'] === 'string' ? meta['root'] : null;
54
57
  }
55
- export async function loadProjectContext(project, docsRoot, bita) {
56
- const context = emptyContext({ project: project && trimmed(project).length > 0 ? project : null, docsRoot: docsRoot ?? null });
57
- if (!bita)
58
- return context;
58
+ export async function loadProjectContext(project, sources) {
59
+ const context = emptyContext({ project: project && trimmed(project).length > 0 ? project : null });
60
+ const { inkwell, bita } = sources;
59
61
  if (context.project) {
60
- const pages = await bita.invoke(['docs', 'page', 'ls', '--project', context.project]).catch(() => null);
61
- if (pages?.ok) {
62
- context.pages = parsePages(pages.data);
63
- context.docsRoot ??= metaRoot(pages.meta);
62
+ if (inkwell) {
63
+ const pages = await inkwell.invoke(['page', 'ls', '--project', context.project]).catch(() => null);
64
+ if (pages?.ok) {
65
+ context.pages = parsePages(pages.data);
66
+ context.docsRoot = metaRoot(pages.meta);
67
+ }
68
+ }
69
+ if (bita) {
70
+ const repos = await bita.invoke(['project', 'repo', 'ls', '--project', context.project]).catch(() => null);
71
+ if (repos?.ok)
72
+ context.repos = parseRepos(repos.data);
64
73
  }
65
- const repos = await bita.invoke(['project', 'repo', 'ls', '--project', context.project]).catch(() => null);
66
- if (repos?.ok)
67
- context.repos = parseRepos(repos.data);
68
74
  }
69
- if (context.docsRoot === null) {
70
- const tree = await bita.invoke(['docs', 'tree']).catch(() => null);
75
+ if (context.docsRoot === null && inkwell) {
76
+ const tree = await inkwell.invoke(['tree']).catch(() => null);
71
77
  if (tree?.ok)
72
78
  context.docsRoot = metaRoot(tree.meta);
73
79
  }
74
80
  return context;
75
81
  }
82
+ export async function entryPages(inkwell, entryId, strict = false) {
83
+ if (!inkwell || entryId === undefined)
84
+ return [];
85
+ const response = await inkwell.invoke(['page', 'ls', '--entry', String(entryId)]).catch(() => null);
86
+ if (response?.ok)
87
+ return parsePages(response.data);
88
+ if (strict)
89
+ throw new RecapError('INKWELL_FAILED', `inkwell page ls --entry ${entryId} failed: ${response?.errorMessage ?? 'no reason given'}`);
90
+ return [];
91
+ }
76
92
  export function meetingProjectName(meeting) {
77
93
  return meeting.bitaEntry?.projectName ?? meeting.wrapup?.project ?? null;
78
94
  }
79
- export async function meetingProject(meeting, bita) {
95
+ export async function meetingProject(meeting, inkwell, linked) {
80
96
  const name = meetingProjectName(meeting);
81
97
  if (name && trimmed(name).length > 0)
82
98
  return name;
83
- const pageId = meeting.wrapup?.pageId ?? meeting.bitaEntry?.pageIds[0];
84
- if (!bita || pageId === undefined)
85
- return null;
86
- const response = await bita.invoke(['docs', 'page', 'show', String(pageId), '--no-markdown']).catch(() => null);
87
- if (!response?.ok || !isRecord(response.data))
99
+ if (!inkwell)
88
100
  return null;
89
- return typeof response.data['projectName'] === 'string' ? response.data['projectName'] : null;
101
+ const pageId = meeting.wrapup?.pageId;
102
+ if (pageId !== undefined) {
103
+ const response = await inkwell.invoke(['page', 'show', String(pageId), '--no-markdown']).catch(() => null);
104
+ if (response?.ok && isRecord(response.data) && typeof response.data['projectName'] === 'string')
105
+ return response.data['projectName'];
106
+ }
107
+ const pages = linked ?? (await entryPages(inkwell, meeting.bitaEntryId));
108
+ return pages.find((page) => page.projectName !== undefined)?.projectName ?? null;
109
+ }
110
+ export async function contextSources(config, meeting) {
111
+ const [inkwell, bita] = await Promise.all([findInkwell(INKWELL_CAPABILITIES.pages), BitaClient.create(config, meeting ? meetingTarget(meeting) : {})]);
112
+ return { inkwell, bita };
113
+ }
114
+ export async function meetingContext(meeting, config) {
115
+ const sources = await contextSources(config, meeting);
116
+ return loadProjectContext(await meetingProject(meeting, sources.inkwell), sources);
90
117
  }
@@ -10,12 +10,11 @@ import { selfCommand } from "../core/self.js";
10
10
  import { suffix, trimmed } from "../core/text.js";
11
11
  import { requireTool } from "../core/tools.js";
12
12
  import { RecapError, errorMessage } from "../errors.js";
13
- import { docsClient, meetingTarget } from "../bita/client.js";
14
13
  import { resultText, runClaude } from "../pipeline/claude.js";
15
14
  import { whisperArguments } from "../pipeline/pipeline.js";
16
15
  import { parseWhisper } from "../pipeline/transcript.js";
17
16
  import { pruneBoard } from "./asking.js";
18
- import { loadProjectContext, meetingProject } from "./context.js";
17
+ import { meetingContext } from "./context.js";
19
18
  import { AutoAskProcess, AutoAskQueue, QuestionDetector } from "./detector.js";
20
19
  import { appendLines, liveFiles, readLines } from "./files.js";
21
20
  import { LiveMerger } from "./merger.js";
@@ -177,10 +176,7 @@ export function makeLiveDetector(dir, meeting, config, log) {
177
176
  const runner = new AutoAskProcess(dir, selfCommand());
178
177
  const asks = new AutoAskQueue({ dir, concurrency: settings.autoAskConcurrency, run: (job) => runner.run(job), cancelRunning: () => runner.cancel(), log });
179
178
  return new QuestionDetector(dir, meeting, settings.autoAskMinSeconds, {
180
- context: async () => {
181
- const docs = await docsClient(config, meetingTarget(meeting));
182
- return loadProjectContext(await meetingProject(meeting, docs), meeting.bitaDocsRoot, docs);
183
- },
179
+ context: () => meetingContext(meeting, config),
184
180
  complete: async (prompt) => resultText(await runClaude({ prompt, cwd: dir, config, tools: [], addDirs: [], model: settings.autoAskModel, restrictTools: [] })),
185
181
  enqueue: (question, origin) => {
186
182
  asks.enqueue(question, origin);
@@ -11,7 +11,7 @@ import { exec } from "../core/proc.js";
11
11
  import { suffix, trimmed } from "../core/text.js";
12
12
  import { requireTool } from "../core/tools.js";
13
13
  import { RecapError, errorMessage } from "../errors.js";
14
- import { docsClient, meetingTarget, requireBita } from "../bita/client.js";
14
+ import { INKWELL_CAPABILITIES, StageSkipped, inkwellForStage } from "../bita/client.js";
15
15
  import { ProposalGenerator } from "../bita/proposals.js";
16
16
  import { runWrapup } from "../bita/wrapup.js";
17
17
  import { runSummaryClaude } from "./claude.js";
@@ -78,9 +78,9 @@ export class Pipeline {
78
78
  recording(meeting) {
79
79
  return recordingPath(this.dir, meeting.mode);
80
80
  }
81
- setStage(stage, status, error, extra) {
81
+ setStage(stage, status, error, extra, details = {}) {
82
82
  return updateMeeting(this.dir, (current) => {
83
- current.stages[stage] = { status, updatedAt: isoNow(), ...(error !== undefined ? { error } : {}) };
83
+ current.stages[stage] = { status, updatedAt: isoNow(), ...(error !== undefined ? { error } : {}), ...details };
84
84
  extra?.(current);
85
85
  });
86
86
  }
@@ -131,6 +131,11 @@ export class Pipeline {
131
131
  }
132
132
  catch (error) {
133
133
  const message = error instanceof RecapError ? error.message : errorMessage(error);
134
+ if (error instanceof StageSkipped) {
135
+ meeting = this.setStage(stage, 'skipped', undefined, undefined, { reason: message, ...(error.hint !== undefined ? { hint: error.hint } : {}) });
136
+ this.log(`${stage}: skipped, ${message}${error.hint !== undefined ? ` (${error.hint})` : ''}`);
137
+ continue;
138
+ }
134
139
  if (stageIsOptional(stage)) {
135
140
  meeting = this.setStage(stage, 'failed', message);
136
141
  this.log(`${stage}: failed, continuing: ${message}`);
@@ -165,7 +170,7 @@ export class Pipeline {
165
170
  case 'proposals':
166
171
  return this.proposals(meeting);
167
172
  case 'wrapup':
168
- return runWrapup(meeting, this.dir, this.config);
173
+ return runWrapup(meeting, this.dir, this.config, { log: this.log });
169
174
  }
170
175
  }
171
176
  async extractAudio(meeting) {
@@ -268,8 +273,7 @@ export class Pipeline {
268
273
  saveSummary(extractSummary(output), meeting, this.dir);
269
274
  }
270
275
  async proposals(meeting) {
271
- const target = meetingTarget(meeting);
272
- const docs = (await docsClient(this.config, target)) ?? (await requireBita(this.config, target));
273
- await new ProposalGenerator(this.config, docs, this.log).run(meeting, this.dir);
276
+ const inkwell = await inkwellForStage(INKWELL_CAPABILITIES.proposals);
277
+ await new ProposalGenerator(this.config, inkwell, this.log).run(meeting, this.dir);
274
278
  }
275
279
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kikedealba/recap",
3
- "version": "0.9.1",
4
- "description": "Meeting recorder CLI: local transcription with whisper.cpp, minutes written by Claude Code, live answers and bita integration.",
3
+ "version": "0.10.0",
4
+ "description": "Meeting recorder CLI: local transcription with whisper.cpp, minutes written by Claude Code, live answers, bita timers and inkwell docs.",
5
5
  "license": "MIT",
6
6
  "author": "Jesus Enrique De Alba Gaytan",
7
7
  "homepage": "https://github.com/KikeDeAlba/recap#readme",
@@ -74,37 +74,57 @@ Errores típicos y qué hacer:
74
74
  | `DEPENDENCY_MISSING`, `MODEL_MISSING` | Falta ffmpeg, whisper-cli, claude o el modelo | `recap setup --install-deps` |
75
75
  | `CAPTURE_UNAVAILABLE` | No hay grabador en este equipo (Windows, Linux, o falta Recap.app) | Graba con otra app y usa `recap import <archivo>`; en mac, `recap setup` |
76
76
 
77
- ## Con bita
77
+ ## Con bita e inkwell
78
+
79
+ Cada herramienta hace una sola cosa:
80
+
81
+ - **bita** lleva el tiempo (cronómetros). recap solo le pide `projects`,
82
+ `project repo ls`, `entries get` y `amend` (título y proyecto).
83
+ - **inkwell** guarda la documentación: páginas, backlog, propuestas, historial
84
+ y la nota de cada entrada de bita (`inkwell note …`).
85
+ - **tally** vuelca a Jira el tiempo de bita (`tally summary --pending`); recap
86
+ no toca Jira.
78
87
 
79
88
  Si `recap status --json` trae `data.bitaLinked: true` (recap está suscrito a
80
89
  los eventos de bita desde `recap setup`), **arranca y para las reuniones desde
81
90
  bita**, no desde recap: así el tiempo queda medido y la grabación sigue al
82
- contador.
91
+ cronómetro.
83
92
 
84
93
  ```sh
85
94
  bita start "<título>" --kind remote-meeting
86
95
  bita start "<título>" --kind in-person-meeting
87
- bita stop <id> --json # sin --did y sin escribir la página
96
+ bita stop <id> --json # sin escribir la página: eso lo hace recap
88
97
  recap wait --bita-entry <id> --json # espera a que todo quede listo
89
98
  ```
90
99
 
100
+ Cambiar el título o el proyecto del cronómetro mientras se graba (`bita amend`)
101
+ no corta ni duplica la grabación: recap solo actualiza los datos de la reunión.
102
+ Solo un cambio de tipo (`--kind`) que cruce la línea de reunión la arranca o la
103
+ detiene.
104
+
91
105
  Al parar, recap hace todo lo demás sin que nadie lo pida:
92
106
 
93
107
  - transcribe y escribe la minuta;
94
- - le pone al contador un título real, si el que tenía era genérico;
95
- - le asigna el proyecto si no tenía y la conversación lo deja claro;
96
- - crea la página en ese proyecto, o agrega una sección a la que ya tenía, y la escribe;
97
- - pasa pendientes y preguntas abiertas al backlog;
98
- - deja la minuta en la sección «Reunión» de la entrada.
108
+ - le pone al cronómetro un título real, si el que tenía era genérico (`bita amend`);
109
+ - le asigna el proyecto si no tenía y la conversación lo deja claro (`bita amend`);
110
+ - crea la página en inkwell, ligada a la entrada (`inkwell page new --from-entry`),
111
+ o agrega una sección a la que ya tenía (`inkwell page ls --entry`), y la escribe;
112
+ - pasa pendientes y hallazgos al backlog de inkwell (`inkwell backlog add`);
113
+ - deja la minuta en la sección «Reunión» de la nota de la entrada
114
+ (`inkwell note save <entrada> --section Reunión --md …`).
99
115
 
100
116
  El resultado está en `data.wrapup` de `recap wait`. Si `wrapup.projectResolved`
101
- es false, pregunta el proyecto y aplícalo con `bita amend`, y mueve la página y
102
- el backlog con inkwell si está instalado (`inkwell page move --project`,
103
- `inkwell backlog edit --project`) o, si no, con `bita docs page move --project`
104
- y `bita backlog edit --project`.
105
-
106
- Las páginas, las propuestas y el backlog se escriben con inkwell cuando está
107
- instalado y ya migró los documentos de bita; si no, con `bita docs`/`bita backlog`.
117
+ es false, pregunta el proyecto, aplícalo con `bita amend <id> --project …` y
118
+ mueve la página y el backlog con `inkwell page move --project` e
119
+ `inkwell backlog edit --project`.
120
+
121
+ **Sin inkwell** la reunión igual queda procesada: las etapas `proposals` y
122
+ `wrapup` quedan en `skipped` con el motivo en `stages.<etapa>.reason` y la pista
123
+ `npm i -g @kikedealba/inkwell && inkwell setup`; la minuta se queda en
124
+ `summary.md` de la carpeta de la reunión. Si inkwell es viejo y no tiene la
125
+ capacidad `docs.entry-notes`, se escribe la página y el backlog pero la minuta
126
+ no pasa a la nota (queda anotado en `process.log`). Después de instalar o
127
+ actualizar inkwell, `recap process <id> --from proposals` lo completa.
108
128
 
109
129
  ## En vivo
110
130
 
@@ -113,8 +133,9 @@ Mientras se graba, recap transcribe por tramos de 5 a 20 s en
113
133
  Sala y `system` Remotos). Es aproximada: la transcripción de después del stop
114
134
  (`transcript.md`) sigue siendo la fuente de verdad.
115
135
 
116
- Para responder una pregunta que hicieron en la reunión, con las páginas de bita
117
- y los repos del proyecto (`bita project repo ls`):
136
+ Para responder una pregunta que hicieron en la reunión, con las páginas de
137
+ inkwell y los repos del proyecto (`bita project repo ls`); sin inkwell responde
138
+ solo con los repos:
118
139
 
119
140
  ```sh
120
141
  recap ask --active --json # la última pregunta de la transcripción
@@ -138,9 +159,10 @@ solo corre una respuesta a la vez y una manual detiene a la automática.
138
159
 
139
160
  ## Cambios propuestos a la documentación
140
161
 
141
- Si la reunión está ligada a bita, la etapa `proposals` (antes de `wrapup`)
142
- detecta los cambios explícitos y firmes a páginas que ya existen y los deja en
143
- la rama `proposal/meeting-<entrada>` de los docs, sin tocar `main`. Ideas,
162
+ Si la reunión está ligada a bita e inkwell está instalado, la etapa `proposals`
163
+ (antes de `wrapup`) detecta los cambios explícitos y firmes a páginas de inkwell
164
+ que ya existen y los deja en la rama `proposal/meeting-<entrada>` de sus docs
165
+ (`inkwell git propose`), sin tocar `main`. Ideas,
144
166
  dudas y lo que se corrigió después no entran.
145
167
 
146
168
  ```sh