ctxr-cli 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +16 -1
  2. package/dist/commands/publish-check.d.ts.map +1 -1
  3. package/dist/commands/publish-check.js +6 -20
  4. package/dist/commands/publish-check.js.map +1 -1
  5. package/dist/config/defaults.d.ts +2 -0
  6. package/dist/config/defaults.d.ts.map +1 -1
  7. package/dist/config/defaults.js +2 -0
  8. package/dist/config/defaults.js.map +1 -1
  9. package/dist/config/schema.d.ts +22 -0
  10. package/dist/config/schema.d.ts.map +1 -1
  11. package/dist/config/schema.js +17 -0
  12. package/dist/config/schema.js.map +1 -1
  13. package/dist/core/checks/organize-checks.d.ts +8 -0
  14. package/dist/core/checks/organize-checks.d.ts.map +1 -1
  15. package/dist/core/checks/organize-checks.js +34 -0
  16. package/dist/core/checks/organize-checks.js.map +1 -1
  17. package/dist/core/checks/types.d.ts +2 -0
  18. package/dist/core/checks/types.d.ts.map +1 -1
  19. package/dist/core/checks/types.js.map +1 -1
  20. package/dist/core/checks/write-lifecycle-checks.d.ts +11 -0
  21. package/dist/core/checks/write-lifecycle-checks.d.ts.map +1 -1
  22. package/dist/core/checks/write-lifecycle-checks.js +61 -0
  23. package/dist/core/checks/write-lifecycle-checks.js.map +1 -1
  24. package/dist/core/git/staged.d.ts.map +1 -1
  25. package/dist/core/git/staged.js +29 -15
  26. package/dist/core/git/staged.js.map +1 -1
  27. package/dist/core/publish/send-copies.d.ts +11 -0
  28. package/dist/core/publish/send-copies.d.ts.map +1 -1
  29. package/dist/core/publish/send-copies.js +26 -0
  30. package/dist/core/publish/send-copies.js.map +1 -1
  31. package/dist/core/registry.d.ts +1 -1
  32. package/dist/core/registry.js +1 -1
  33. package/dist/core/skills.d.ts +18 -2
  34. package/dist/core/skills.d.ts.map +1 -1
  35. package/dist/core/skills.js +51 -5
  36. package/dist/core/skills.js.map +1 -1
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/package.json +2 -2
  40. package/templates/skills/ctxr-circleback/sweep.mjs +229 -0
  41. package/templates/skills/ctxr-circleback.md +70 -0
  42. package/templates/skills/ctxr-publish.md +5 -0
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ // Work out which Circleback meetings this store owes, and render their captures on a review branch.
3
+ //
4
+ // Shipped by contexture as part of the ctxr-circleback skill; `ctxr update` rewrites it, so do not edit
5
+ // it in a store. Node standard library only. It drives Circleback's own CLI (`cb`), which holds its own
6
+ // credential, plus `git` and `gh`; it never reads or stores a credential itself.
7
+ //
8
+ // The store's routing is in settings.json beside this file. The store is CONTEXTURE_STORE_ROOT, or the
9
+ // git repository the current directory is in.
10
+ //
11
+ // Two facts about `cb` shape this file: search results carry no tags (every `tags` field is empty), so
12
+ // tags come from `cb meetings read`; and a meeting older than the plan's history window reads back with
13
+ // a notice in place of its notes, which is how a locked meeting is recognised.
14
+
15
+ import { execFileSync } from 'node:child_process';
16
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
22
+ const LOOKBACK_DAYS = 35;
23
+ const URGENT_DAYS = 25;
24
+ const DECLINE_REPORT_DAYS = 8;
25
+ const PAGE_SIZE = 20;
26
+ const READ_BATCH = 50;
27
+ const BRANCH = 'circleback-capture';
28
+ const LOCKED_NOTICE = 'outside the history available';
29
+
30
+ function run(command, args, { cwd, check = true } = {}) {
31
+ try {
32
+ return execFileSync(command, args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, timeout: 180_000, stdio: ['ignore', 'pipe', 'pipe'] });
33
+ } catch (error) {
34
+ if (!check) return error.stdout ?? '';
35
+ const detail = String(error.stderr || error.message).trim().slice(0, 300);
36
+ throw new Error(`${command} ${args.slice(0, 2).join(' ')} failed: ${detail}`);
37
+ }
38
+ }
39
+
40
+ function cb(args) {
41
+ return JSON.parse(run('cb', [...args, '--json']));
42
+ }
43
+
44
+ function storeRoot() {
45
+ const fromEnv = process.env.CONTEXTURE_STORE_ROOT;
46
+ if (fromEnv) return fromEnv;
47
+ return run('git', ['rev-parse', '--show-toplevel']).trim();
48
+ }
49
+
50
+ function tagNames(meeting) {
51
+ return new Set((meeting.tags ?? []).map((tag) => (typeof tag === 'string' ? tag : tag.name)));
52
+ }
53
+
54
+ function asList(value) {
55
+ return Array.isArray(value) ? value : [value];
56
+ }
57
+
58
+ function line(meeting) {
59
+ return `${meeting.createdAt.slice(0, 10)} | ${meeting.id} | ${meeting.name.trim()}`;
60
+ }
61
+
62
+ /** Every meeting from the lookback window, read in full so its tags are present. */
63
+ function recentMeetings() {
64
+ const ids = [];
65
+ for (let page = 0; ; page += 1) {
66
+ const batch = cb(['meetings', 'search', '--last', String(LOOKBACK_DAYS), '--page', String(page)]);
67
+ ids.push(...batch.map((meeting) => meeting.id));
68
+ if (batch.length < PAGE_SIZE) break;
69
+ }
70
+ const meetings = [];
71
+ for (let i = 0; i < ids.length; i += READ_BATCH) meetings.push(...asList(cb(['meetings', 'read', ...ids.slice(i, i + READ_BATCH)])));
72
+ return meetings.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
73
+ }
74
+
75
+ /** Circleback ids already in the default branch, and in each open pull request's diff. */
76
+ function presence(root, settings) {
77
+ run('git', ['fetch', '-q', 'origin'], { cwd: root });
78
+ const onMain = new Set(
79
+ run('git', ['grep', '-h', '-o', '-E', 'circleback/[A-Za-z0-9_-]+', `origin/${settings.default_branch}`], { cwd: root, check: false })
80
+ .split('\n')
81
+ .filter(Boolean)
82
+ .map((match) => match.slice('circleback/'.length)),
83
+ );
84
+ const inPullRequest = new Map();
85
+ for (const pr of JSON.parse(run('gh', ['pr', 'list', '--state', 'open', '--json', 'number'], { cwd: root }))) {
86
+ const diff = run('gh', ['pr', 'diff', String(pr.number)], { cwd: root, check: false });
87
+ for (const match of diff.matchAll(/circleback\/([A-Za-z0-9_-]+)/g)) if (!inPullRequest.has(match[1])) inPullRequest.set(match[1], pr.number);
88
+ }
89
+ return { onMain, inPullRequest };
90
+ }
91
+
92
+ /**
93
+ * The review worktree, at a fixed path so a later run finds it: continuing the open pull request's branch
94
+ * when there is one, else fresh from the default branch. Whatever an earlier run left there is cleared
95
+ * first, because an agent running unattended may not be allowed to delete it.
96
+ */
97
+ function prepareWorktree(root, settings) {
98
+ const worktree = path.join(tmpdir(), `ctxr-circleback-${path.basename(root)}`);
99
+ run('git', ['worktree', 'remove', '--force', worktree], { cwd: root, check: false });
100
+ rmSync(worktree, { recursive: true, force: true });
101
+ run('git', ['worktree', 'prune'], { cwd: root });
102
+ const open = JSON.parse(run('gh', ['pr', 'list', '--head', BRANCH, '--state', 'open', '--json', 'number'], { cwd: root }));
103
+ if (open.length > 0) {
104
+ run('git', ['fetch', '-q', 'origin', BRANCH], { cwd: root });
105
+ run('git', ['worktree', 'add', '-B', BRANCH, worktree, `origin/${BRANCH}`], { cwd: root });
106
+ run('git', ['merge', '-q', '--no-edit', `origin/${settings.default_branch}`], { cwd: worktree });
107
+ } else {
108
+ run('git', ['push', '-q', 'origin', '--delete', BRANCH], { cwd: root, check: false });
109
+ run('git', ['worktree', 'add', '-B', BRANCH, worktree, `origin/${settings.default_branch}`], { cwd: root });
110
+ }
111
+ return { worktree, continuing: open[0]?.number };
112
+ }
113
+
114
+ function captureFileName(meeting) {
115
+ const slug = meeting.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || meeting.id;
116
+ return `circleback-${meeting.createdAt.slice(0, 10)}-${slug}.md`;
117
+ }
118
+
119
+ /** The mechanical part of a capture. The speaker judgment is the agent's, added afterwards. */
120
+ function renderCapture(meeting, transcript, settings) {
121
+ const segments = asList(transcript)[0]?.transcript ?? [];
122
+ const seconds = Math.round(Number(meeting.duration ?? 0));
123
+ const out = [
124
+ '---',
125
+ `title: ${JSON.stringify(meeting.name.trim())}`,
126
+ 'source_type: circleback',
127
+ `source_id: circleback/${meeting.id}`,
128
+ 'capture_type: meeting',
129
+ '---',
130
+ `# ${meeting.name.trim()}`,
131
+ '',
132
+ `Circleback meeting \`${meeting.id}\`, ${meeting.createdAt} (upload time, UTC), duration ${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s, ${segments.length} transcript segments.`,
133
+ '',
134
+ '*The recording URL Circleback exposes is a signed, expiring link, so it is deliberately not recorded here. Retrieve it from Circleback by meeting id if it is needed.*',
135
+ '',
136
+ '## Attendees',
137
+ ...(meeting.attendees ?? []).map((a) => `- ${a.name || '(unnamed)'}${a.email ? ` (${a.email})` : ''}`),
138
+ '',
139
+ '## Action items (Circleback)',
140
+ ];
141
+ const items = meeting.actionItems ?? [];
142
+ for (const item of items) {
143
+ const description = (item.description ?? '').split(/\s+/).filter(Boolean).join(' ');
144
+ out.push(`- **${(item.title ?? '').trim()}** — assignee ${item.assignee?.name ?? 'unassigned'}, status ${item.status ?? '?'}.${description ? ` "${description}"` : ''}`);
145
+ }
146
+ if (items.length === 0) out.push('(none)');
147
+ out.push(
148
+ '',
149
+ "## Summary (Circleback's own, not the record)",
150
+ '',
151
+ (meeting.notes ?? '(none)').trim(),
152
+ '',
153
+ `## ${settings.required_section ?? 'Transcript'}`,
154
+ '',
155
+ "*Verbatim from Circleback, speaker-labelled, with start timestamps in seconds. Unedited: mis-transcriptions and speaker-attribution slips are the service's own.*",
156
+ '',
157
+ );
158
+ for (const segment of segments) {
159
+ const text = (segment.text ?? '').split(/\s+/).filter(Boolean).join(' ');
160
+ out.push(`**${segment.speaker || 'Unknown'}** (${segment.timestamp ?? 0}): ${text}`, '');
161
+ }
162
+ return `${out.map((l) => l.replace(/\s+$/, '')).join('\n').trimEnd()}\n`;
163
+ }
164
+
165
+ function section(title, lines) {
166
+ return [`\n${title} (${lines.length}):`, ...(lines.length > 0 ? lines : ['(none)'])];
167
+ }
168
+
169
+ function main() {
170
+ const settings = JSON.parse(readFileSync(path.join(HERE, 'settings.json'), 'utf8'));
171
+ const root = storeRoot();
172
+ const otherTags = new Set(settings.other_tags);
173
+ const { onMain, inPullRequest } = presence(root, settings);
174
+ const meetings = recentMeetings();
175
+ const now = Date.now();
176
+ const ageDays = (meeting) => (now - Date.parse(meeting.createdAt)) / 86_400_000;
177
+
178
+ const owed = [];
179
+ const locked = [];
180
+ const untagged = [];
181
+ const multiStore = [];
182
+ const declined = [];
183
+ let present = 0;
184
+ for (const meeting of meetings) {
185
+ const tags = tagNames(meeting);
186
+ const isLocked = (meeting.notes ?? '').includes(LOCKED_NOTICE);
187
+ const here = onMain.has(meeting.id) || inPullRequest.has(meeting.id);
188
+ const others = [...tags].filter((tag) => otherTags.has(tag));
189
+ if (tags.has(settings.tag) && here) present += 1;
190
+ if (tags.size === 0 && !here) untagged.push(`${line(meeting)}${isLocked ? ' (locked)' : ageDays(meeting) >= URGENT_DAYS ? ' (URGENT, locks soon)' : ''}`);
191
+ if (tags.has(settings.tag) && others.length > 0) multiStore.push(`${line(meeting)} (also ${others.join(', ')})`);
192
+ if (tags.has(settings.decline_tag) && ageDays(meeting) <= DECLINE_REPORT_DAYS) declined.push(line(meeting));
193
+ const isOwed = tags.has(settings.tag) && others.length === 0 && !tags.has(settings.decline_tag) && !here;
194
+ if (isOwed && isLocked) locked.push(line(meeting));
195
+ else if (isOwed) owed.push(meeting);
196
+ }
197
+
198
+ const report = [
199
+ `Store tag \`${settings.tag}\`. Meetings in the last ${LOOKBACK_DAYS} days: ${meetings.length}. Already in this store or an open pull request: ${present}.`,
200
+ ...section('TO CAPTURE — date | id | title', owed.map(line)),
201
+ ];
202
+ if (owed.length > 0) {
203
+ const { worktree, continuing } = prepareWorktree(root, settings);
204
+ const inbox = path.join(worktree, settings.inbox_path);
205
+ mkdirSync(inbox, { recursive: true });
206
+ report.push(`\nWORKTREE: ${worktree} (branch \`${BRANCH}\`, ${continuing ? `continuing pull request #${continuing}` : `fresh from origin/${settings.default_branch}`})`);
207
+ report.push('CAPTURES RENDERED:');
208
+ for (const meeting of owed) {
209
+ const transcript = cb(['transcripts', 'read', meeting.id]);
210
+ const file = path.join(inbox, captureFileName(meeting));
211
+ writeFileSync(file, renderCapture(meeting, transcript, settings));
212
+ report.push(file);
213
+ }
214
+ }
215
+ report.push(
216
+ ...section(`LOCKED — tagged \`${settings.tag}\`, never captured, no longer readable in full`, locked),
217
+ ...section('UNTAGGED', untagged),
218
+ ...section('TAGGED FOR MORE THAN ONE STORE', multiStore),
219
+ ...section(`DECLINED IN THE LAST ${DECLINE_REPORT_DAYS} DAYS — tagged \`${settings.decline_tag}\`, for confirmation`, declined),
220
+ );
221
+ process.stdout.write(`${report.join('\n')}\n`);
222
+ }
223
+
224
+ try {
225
+ main();
226
+ } catch (error) {
227
+ // A scheduled job puts this output in the agent's prompt; it must see a failure, not an empty list.
228
+ process.stdout.write(`COLLECTION FAILED: ${error.message}\n`);
229
+ }
@@ -0,0 +1,70 @@
1
+ Capture the Circleback meetings this store owes, on a review branch, without deciding the list by hand. One
2
+ Circleback account can feed several stores, and a tag on each meeting says which store it belongs to. This store's
3
+ routing is in `settings.json` beside this file, rendered from the `circleback` block in `contexture.yaml`.
4
+
5
+ ## When
6
+
7
+ On a request such as "sweep Circleback" or "capture my meetings", and from a scheduled job. A scheduled job runs the
8
+ runner before you start and puts its report in your prompt, so begin at step 2.
9
+
10
+ ## 1. Run the runner
11
+
12
+ From the store root:
13
+
14
+ ```
15
+ node __SKILLS_PATH__/ctxr-circleback/sweep.mjs
16
+ ```
17
+
18
+ It needs Circleback's command-line tool (`cb`) signed in, plus `git` and `gh`. It reads every meeting from the last
19
+ 35 days with its tags, compares them with this store's default branch and its open pull requests, and prints a report.
20
+ When the store owes any meeting, it also prepares a review-branch worktree and writes each owed meeting's capture into
21
+ `__INBOX_PATH__` there, under CAPTURES RENDERED.
22
+
23
+ The report is authoritative. Do not work the list out again yourself, and do not rebuild the files it wrote. If it
24
+ prints `COLLECTION FAILED`, report that line and stop.
25
+
26
+ What the runner writes is the mechanical part of a capture: the source type and id, the meeting's metadata, its
27
+ attendees, Circleback's action items and summary (marked as the service's derivation, not the record), and the
28
+ transcript verbatim. It never writes the recording URL, which is a signed, expiring link and so a credential.
29
+
30
+ ## 2. Add the judgment
31
+
32
+ For each file under CAPTURES RENDERED, read it, and after `## Attendees` add a `### Speaker caveat` when the speaker
33
+ labels cannot be trusted: generic "Participant N" labels, or a summary that gives a position or a role to the wrong
34
+ person. Say what is unreliable. Name who a label most likely is only where the transcript itself makes it clear.
35
+
36
+ Never edit the transcript or Circleback's summary, and never add `source_hash` or `ingested`; ingest assigns both.
37
+
38
+ Then, from the worktree, run `ctxr source check <file> --source-id circleback/<id>`. If the verdict is not `new`,
39
+ leave that file unstaged and report it.
40
+
41
+ ## 3. Publish
42
+
43
+ Stage exactly the capture files and commit on the review branch. The store's commit hook is the validation; add no
44
+ checks of your own on top of it. Push the branch, and open a pull request against `__DEFAULT_BRANCH__` if none is
45
+ open; a later run adds to the open one. Its body lists each meeting (date, title, id) and says the captures still need
46
+ an interactive ingest through `ctxr-ingest-orchestration`.
47
+
48
+ Never merge the pull request and never ingest here.
49
+
50
+ ## 4. Report
51
+
52
+ Give the counts (captured, already present), the pull request link, and the runner's other sections copied as they
53
+ are:
54
+
55
+ - **LOCKED** — tagged for this store but no longer readable in full, so it cannot be captured.
56
+ - **UNTAGGED**, **TAGGED FOR MORE THAN ONE STORE** and **DECLINED IN THE LAST 8 DAYS**, when the runner prints them.
57
+
58
+ If nothing was owed and every section is empty, say "Nothing missing".
59
+
60
+ ## How a meeting is routed
61
+
62
+ - A meeting is this store's when it carries this store's tag, does not carry the decline tag, and carries no other
63
+ store's tag.
64
+ - An untagged meeting is undecided, not skipped. It gets a tag in Circleback; ask the operator when the home is
65
+ unclear.
66
+ - A meeting tagged for two stores is captured nowhere until the operator resolves it. A store that already holds it
67
+ keeps it.
68
+ - Closing a capture pull request does not reject a meeting. Retagging it with the decline tag does.
69
+ - Circleback's free plan hides a meeting's full notes after about 30 days. The runner looks back 35 days, so a weekly
70
+ run sees each meeting several times before then.
@@ -206,6 +206,11 @@ together.
206
206
  When the page changes, write a new dated send copy. Do not change or remove an earlier one. Why: an
207
207
  earlier file can be the one a recipient got, and the folder then shows what you sent on each date.
208
208
 
209
+ The commit gate checks both rules in every store. It refuses a commit that adds a send copy with no
210
+ date in its name. It also refuses a commit that changes a send copy that is already committed. Why: the
211
+ skill alone cannot stop an overwrite, and the gate runs on every commit. To send again, write a new
212
+ dated file. You can move or delete a send copy together with its page.
213
+
209
214
  Put a file that the page uses, but that you did not make, in the page's `assets/` folder. Why:
210
215
  `ctxr publish check` reads only the files at the folder root as the page's send copies.
211
216