toga-ai 1.0.168 → 1.0.170

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.
@@ -34,10 +34,12 @@ knowledge/
34
34
  │ └── standards/*.md # 2.0 coding standards (_underscore)
35
35
  ├── standalone/
36
36
  │ └── apps/<repo>/architecture.md + features/*.md # non-PHP apps (e.g. togatech); no core
37
- └── clients/<client>/
38
- ├── profile.md
39
- ├── features/*.md # client-specific feature overrides (link back to apps/)
40
- └── workflows/*.md # client business processes
37
+ ├── clients/<client>/
38
+ │ ├── profile.md
39
+ │ ├── features/*.md # client-specific feature overrides (link back to apps/)
40
+ │ └── workflows/*.md # client business processes
41
+ └── sessions/ # team-shared resumable sessions (NOT knowledge docs)
42
+ └── YYYY-MM-DD-<slug>-<author>.md
41
43
  ```
42
44
 
43
45
  **App folders are keyed by REPO name** (`worker2`, `_underscore`, `api2`, `library`),
@@ -211,6 +213,39 @@ Never hand-edited. `node knowledge.js index` regenerates the master `INDEX.md`,
211
213
  `<fw>/apps/<repo>/INDEX.md`, and each `clients/<client>/INDEX.md` from frontmatter, so
212
214
  they can never drift from the docs.
213
215
 
216
+ ## Team-shared sessions (`knowledge/sessions/`, `type: session`)
217
+
218
+ Sessions are **resumable work state**, not knowledge docs — they let one developer save an
219
+ in-progress task and another (or future-you, on another machine) resume it. They ride the
220
+ same git rails as `capture` (rebase-before-push to `_main`), but are managed by the
221
+ `session-save` / `session-resume` skills and the `knowledge.js session-*` commands, **not**
222
+ by `capture`.
223
+
224
+ - **File:** `knowledge/sessions/YYYY-MM-DD-<slug>-<author>.md`. The `<author>` suffix lets
225
+ two developers reuse a slug without collision.
226
+ - **Frontmatter** (its own schema — distinct from the doc frontmatter above):
227
+ ```yaml
228
+ type: session
229
+ slug: <kebab-slug>
230
+ title: <short human title>
231
+ author: <kb-username>
232
+ repos: [<repo>, …] # repos touched (free-form; NOT checked against registry)
233
+ framework: "1.0" | "2.0" | "both" | "standalone"
234
+ client: <slug> | shared
235
+ status: active | stale # stale = abandoned / safe to delete
236
+ created: YYYY-MM-DD
237
+ updated: YYYY-MM-DD
238
+ ```
239
+ - **Not validated as docs.** `allDocs()` walks only `1.0/`, `2.0/`, `standalone/`, and
240
+ `clients/`, so `validate` and `index` deliberately ignore `sessions/`. `session-save`
241
+ runs its own lightweight frontmatter check and the **same secret scan** before pushing —
242
+ never paste credentials into a session body.
243
+ - **Commands:** `session-save --file=`, `session-list [--stale=N --mine --json]`,
244
+ `session-show <id>`, `session-delete <id>`. List/show/delete read from `origin/_main` after
245
+ a `git fetch`, so they reflect the freshest team state without touching the working tree.
246
+ - **Cleanup is manual.** `session-list` flags sessions older than 14 days as `STALE`;
247
+ developers retire them with `/session-resume delete <id>` (no auto-deletion).
248
+
214
249
  ## Integrity
215
250
 
216
251
  `node knowledge.js validate` runs after every `capture` write and enforces:
@@ -21,7 +21,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
21
21
  - **dbchanges2** (Database Changes) _(framework core)_ — 1 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
22
22
  - **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
23
23
  - **saml** (SAML SSO Gateway) — 2 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
24
- - **toga2-view** (TOGa View Frontend) — 0 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
24
+ - **toga2-view** (TOGa View Frontend) — 1 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
25
25
  - **toga2-hub** (TOGa Hub) — 2 doc(s) → [2.0/apps/toga2-hub/INDEX.md](2.0/apps/toga2-hub/INDEX.md)
26
26
  - **talos** (TOGa IQ) — 6 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
27
27
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
@@ -4,4 +4,5 @@
4
4
  |-----|-----------|---------|-------|
5
5
  | [Rate Monthly Reconciliation Report](features/monthly-reconciliation-report.md) | 1.0 | A monthly cron that emails an Excel reconciliation report covering all Rate subscription sales orders and their linked PayPal payments for the prior calendar mo | worker/crons/notifications/reports/rate/send_monthly_rate_purchases_report.php, worker/schedules/cron.worker.notification.json |
6
6
  | [Rate SAML SSO](features/saml-sso.md) | 2.0 | Rate uses Azure AD as its IdP (`login.rate.com`). | _underscore/Model/Rate/ClientAuthentication.php, saml/Controller/Index.php, toga2-view/src/hooks/useAuthenticationFlow.ts |
7
+ | [Service Card Entitlement Display](features/service-card-entitlements.md) | 2.0 | Rate's home and services pages display one service card per purchased entitlement. | src/components/ServiceCard/ServiceCard.tsx, src/components/ServiceCard/index.ts, src/hooks/useBundleServices.ts, src/pages/Home/api/homeApi.ts, src/pages/Home/view/HomePage.tsx, src/pages/Home/viewModels/useHomePageViewModel.ts, src/pages/Services/view/ServicesPage.tsx, src/pages/Services/viewModels/useServicePageViewModel.ts |
7
8
  | [Rate](profile.md) | 2.0 | Rate is a mortgage/lending client. | |
@@ -0,0 +1,103 @@
1
+ ---
2
+ title: "Service Card Entitlement Display"
3
+ framework: "2.0"
4
+ repo: toga2-view
5
+ project: TOGa View
6
+ client: rate
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-06-23
10
+ owners: ["bala"]
11
+ files:
12
+ - src/components/ServiceCard/ServiceCard.tsx
13
+ - src/components/ServiceCard/index.ts
14
+ - src/hooks/useBundleServices.ts
15
+ - src/pages/Home/api/homeApi.ts
16
+ - src/pages/Home/view/HomePage.tsx
17
+ - src/pages/Home/viewModels/useHomePageViewModel.ts
18
+ - src/pages/Services/view/ServicesPage.tsx
19
+ - src/pages/Services/viewModels/useServicePageViewModel.ts
20
+ related:
21
+ - clients/rate/profile.md
22
+ ---
23
+
24
+ ## Summary
25
+
26
+ Rate's home and services pages display one service card per purchased entitlement. The card
27
+ shows a hero image, title, Active badge, contract number, plan, price (or address for warranty
28
+ cards), renewal date, and a Manage button. Previously the UI showed one card per bundle type
29
+ regardless of how many entitlements the user had purchased.
30
+
31
+ ## Key files / entry points
32
+
33
+ - `ServiceCard.tsx` — the card component; accepts `serviceType` to switch the second data row
34
+ between Price (tech) and Address (warranty)
35
+ - `useBundleServices.ts` — shared hook returning one `BundleService` per active entitlement;
36
+ used by both `useHomePageViewModel` and `useServicePageViewModel`
37
+ - `homeApi.ts` — `getActiveServices()` fetches entitlements with subscription and address fields
38
+ - `HomePage.tsx` — shows max 3 cards; "View All Services" tertiary text button links to `/services`
39
+ - `ServicesPage.tsx` — shows all entitlements in a 2-column grid, scrollable
40
+
41
+ ## How it works
42
+
43
+ 1. `getActiveServices(borrowerId)` queries `Entitlements` with:
44
+ - INNER JOIN: `Contacts`, `Customers`, `Subscriptions`
45
+ - LEFT JOIN (`ojoin`): `ContactAddresses`, `Addresses` — keeps entitlements whose contact
46
+ has no address instead of dropping the row
47
+ - Fields: `uuid`, `number`, `saleItem.title/uuid`, `subscription.isActive/amount/frequency/dateEnd`,
48
+ `contact.primaryContactAddress.address.line1`
49
+
50
+ 2. `useBundleServices` filters `subscription.isActive === true` and builds one `BundleService`
51
+ per entitlement. It also fetches bundles to resolve `bundleUuid` for navigation.
52
+
53
+ 3. `detectServiceType(title)` classifies each card:
54
+ - `'warranty'` — title includes "warranty" or "whole home" (and NOT "tech")
55
+ - `'tech'` — title includes "tech" or "support"
56
+ - `'other'` — everything else
57
+
58
+ 4. `ServiceCard` renders the data rows based on `serviceType`:
59
+ - Tech / other → row 2: **Price** (`$XX.XX`)
60
+ - Warranty → row 2: **Address** (`contact.primaryContactAddress.address.line1`)
61
+
62
+ 5. Home page slices `allServices` to 3 (`HOME_SERVICE_LIMIT`). If more exist, a tertiary
63
+ "View All Services" text link appears below the cards pointing to `/services`.
64
+
65
+ 6. Services page renders all entitlements with no cap in a `grid-cols-1 md:grid-cols-2` grid.
66
+
67
+ ## Data model
68
+
69
+ Address path in DB:
70
+ ```
71
+ Entitlements.contactId
72
+ → Contacts.primaryContactAddressId
73
+ → ContactAddresses.id → ContactAddresses.addressId
74
+ → Addresses.line1
75
+ ```
76
+
77
+ Contract number comes from `Entitlements.number` (auto-incremented with ET prefix, e.g. `ET100017`).
78
+
79
+ ## Client variations
80
+
81
+ This feature is Rate-specific. The warranty card showing Address instead of Price is driven
82
+ by Rate's product mix (Whole Home Warranty + Tech Support). Other clients with different
83
+ product types would need `detectServiceType` extended or overridden.
84
+
85
+ ## Gotchas / known issues
86
+
87
+ - **`ojoin` is required for address** — using INNER JOIN (`join`) drops every entitlement
88
+ whose contact has no `primaryContactAddressId`, silently hiding those cards.
89
+ - **`T00:00:00` in date formatting** — `formatDate` appends this before parsing to avoid
90
+ timezone offset shifting the renewal date back by one day.
91
+ - **Per-entitlement, not per-bundle** — `useBundleServices` intentionally does not dedup.
92
+ A user with 8 entitlements of the same product sees 8 cards. The old dedup-by-bundle
93
+ behavior (1 card per service type) was the previous design.
94
+ - **Inactive bundles are not shown** — the new design only renders active entitlements.
95
+ The "Add a subscription" dashed button (handled by `useAddSubscription` hook +
96
+ `AddSubscriptionSheet`) replaces the old "Get Started" inactive bundle cards.
97
+ - **`useBundleServices` is shared** — both `useHomePageViewModel` and `useServicePageViewModel`
98
+ call it. Changes to the hook affect both pages.
99
+
100
+ ## Change history
101
+
102
+ - 2026-06-23 — Initial implementation: per-entitlement card display, new ServiceCard component,
103
+ address via LEFT JOIN, 3-card home limit with View All Services tertiary button (bala)
package/knowledge.js CHANGED
@@ -21,6 +21,11 @@
21
21
  * manifest
22
22
  * kickoff-preflight --repos=a,b,c --client=<slug> --layer=<...> --q=<...>
23
23
  * publish --msg= --mirror= --branch=
24
+ * session-save --file=<path> --msg= --branch=
25
+ * session-list --stale=<days> --mine[=<author>] --json --branch=
26
+ * session-show <id> --branch=
27
+ * session-delete <id> --msg= --branch=
28
+ * skills --installed=<.claude/skills dir> --json
24
29
  */
25
30
 
26
31
  'use strict';
@@ -723,6 +728,221 @@ function cmdValidate() {
723
728
  else console.log('validate: FAILED — see errors above');
724
729
  }
725
730
 
731
+ /* ------------------------------------------------------------------ */
732
+ /* team-shared sessions — knowledge/sessions/<id>.md ride the same git */
733
+ /* rails as publish (rebase-before-push to _main). allDocs() does NOT */
734
+ /* walk sessions/, so validate/index ignore them; session-save runs its */
735
+ /* own lightweight frontmatter + secret check below. */
736
+ /* ------------------------------------------------------------------ */
737
+
738
+ const SESSIONS_DIR = path.join(ROOT, 'sessions');
739
+ const SESSION_REQUIRED = ['type', 'slug', 'title', 'author', 'status', 'created', 'updated'];
740
+
741
+ function gitRun(cmd, timeout) {
742
+ const opts = { cwd: __dirname, encoding: 'utf8', stdio: 'pipe' };
743
+ if (timeout) opts.timeout = timeout;
744
+ return require('child_process').execSync(cmd, opts);
745
+ }
746
+
747
+ function isGitClone() {
748
+ try { gitRun('git rev-parse --git-dir'); return true; } catch { return false; }
749
+ }
750
+
751
+ function currentAuthor() {
752
+ try { return gitRun('git config user.name').trim(); } catch { return ''; }
753
+ }
754
+
755
+ /* commit whatever is already staged, then rebase-before-push (3x retry */
756
+ /* for the CI version-bump race). Caller stages via git add / git rm. */
757
+ /* Returns a bare status token; callers map it to a SESSION:/SKILLS: line.*/
758
+ function pushStaged(opts) {
759
+ const branch = (opts && opts.branch) || '_main';
760
+ const restrict = opts && opts.restrictPrefix;
761
+ const staged = gitRun('git diff --cached --name-only').split('\n').map(s => s.trim()).filter(Boolean);
762
+ if (!staged.length) return 'NO_CHANGES';
763
+ if (restrict) {
764
+ const outside = staged.filter(f => !f.startsWith(restrict));
765
+ if (outside.length) { gitRun('git reset -q'); return 'ABORT_OUTSIDE:' + outside.join(', '); }
766
+ }
767
+ const msg = String((opts && opts.msg) || 'sessions: update').replace(/["\\]/g, "'").slice(0, 200);
768
+ gitRun(`git commit -m "${msg}"`);
769
+ for (let attempt = 1; attempt <= 3; attempt++) {
770
+ try {
771
+ gitRun('git fetch origin');
772
+ gitRun(`git rebase origin/${branch}`);
773
+ gitRun(`git push origin HEAD:${branch}`);
774
+ return 'PUSHED';
775
+ } catch (e) {
776
+ let status = '';
777
+ try { status = gitRun('git status --porcelain'); } catch { /* ignore */ }
778
+ if (/^(UU|AA|DD|AU|UA|DU|UD) /m.test(status)) {
779
+ try { gitRun('git rebase --abort'); } catch { /* ignore */ }
780
+ return 'CONFLICT';
781
+ }
782
+ // else: almost certainly a fresh CI bump (non-fast-forward) — retry
783
+ }
784
+ }
785
+ return 'PUSH_FAILED';
786
+ }
787
+
788
+ function reportSession(status, branch) {
789
+ const br = branch || '_main';
790
+ if (status === 'PUSHED') { console.log(`SESSION: PUSHED to ${br} — teammates see it on the next /session-resume (git fetch)`); return; }
791
+ if (status === 'DELETED') { console.log(`SESSION: DELETED from ${br}`); return; }
792
+ if (status === 'NO_CHANGES') { console.log('SESSION: NO_CHANGES — nothing new to push'); return; }
793
+ if (status === 'CONFLICT') { console.log(`SESSION: CONFLICT — rebase onto origin/${br} hit a real conflict; resolve manually, then retry`); process.exitCode = 1; return; }
794
+ if (status && status.startsWith('ABORT_OUTSIDE')) { console.log('SESSION: ABORT_OUTSIDE_SESSIONS — refusing to commit non-session files: ' + status.slice('ABORT_OUTSIDE:'.length)); process.exitCode = 1; return; }
795
+ console.log(`SESSION: PUSH_FAILED after 3 attempts — run manually: git -C "${__dirname}" push origin HEAD:${br}`); process.exitCode = 1;
796
+ }
797
+
798
+ function fetchQuiet() {
799
+ try { gitRun('git fetch origin', 6000); return true; } catch { return false; }
800
+ }
801
+
802
+ /* List session files from origin/<branch> WITHOUT touching the working */
803
+ /* tree (so a teammate's just-pushed session shows up after a fetch even */
804
+ /* if it isn't checked out locally). Falls back to the working tree. */
805
+ function listSessionDocs(branch, doFetch) {
806
+ const br = branch || '_main';
807
+ if (isGitClone()) {
808
+ if (doFetch) fetchQuiet();
809
+ let names = null;
810
+ try { names = gitRun(`git ls-tree -r --name-only origin/${br} knowledge/sessions/`).split('\n').map(s => s.trim()).filter(f => f.endsWith('.md')); }
811
+ catch { names = null; }
812
+ if (names && names.length) {
813
+ const out = [];
814
+ for (const rel of names) {
815
+ try { out.push({ id: path.basename(rel, '.md'), rel, content: gitRun(`git show origin/${br}:${rel}`) }); } catch { /* skip */ }
816
+ }
817
+ if (out.length) return out;
818
+ }
819
+ }
820
+ // fallback: local working tree
821
+ if (!fs.existsSync(SESSIONS_DIR)) return [];
822
+ return fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.md')).map(f => ({
823
+ id: path.basename(f, '.md'), rel: 'knowledge/sessions/' + f, content: fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'),
824
+ }));
825
+ }
826
+
827
+ function ageDays(dateStr) {
828
+ const m = String(dateStr || '').match(/(\d{4})-(\d{2})-(\d{2})/);
829
+ if (!m) return null;
830
+ const d = Date.UTC(+m[1], +m[2] - 1, +m[3]);
831
+ const now = new Date();
832
+ const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
833
+ return Math.round((today - d) / 86400000);
834
+ }
835
+
836
+ function padTo(s, n) { s = String(s); return s.length >= n ? s : s + ' '.repeat(n - s.length); }
837
+ function clip(s, n) { s = String(s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
838
+
839
+ function cmdSessionSave(args) {
840
+ const file = args.file;
841
+ if (!file) return fail('session-save requires --file=<path>');
842
+ const abs = path.resolve(String(file));
843
+ if (abs !== path.join(SESSIONS_DIR, path.basename(abs)) && !abs.startsWith(SESSIONS_DIR + path.sep)) {
844
+ console.log('SESSION: ABORT_OUTSIDE_SESSIONS — file must live under knowledge/sessions/'); process.exitCode = 1; return;
845
+ }
846
+ if (!fs.existsSync(abs)) return fail('session file not found: ' + abs);
847
+ const content = fs.readFileSync(abs, 'utf8');
848
+ const { data } = parseFrontmatter(content);
849
+ const missing = SESSION_REQUIRED.filter(k => !data[k]);
850
+ if (missing.length) { console.log('SESSION: INVALID — missing frontmatter: ' + missing.join(', ')); process.exitCode = 1; return; }
851
+ if (data.type !== 'session') { console.log('SESSION: INVALID — type must be "session"'); process.exitCode = 1; return; }
852
+ if (!['active', 'stale'].includes(data.status)) { console.log('SESSION: INVALID — status must be active|stale'); process.exitCode = 1; return; }
853
+ const secrets = scanSecrets(content);
854
+ if (secrets.length) { console.log('SESSION: SECRET — ' + secrets.map(s => `line ${s.line}: ${s.reason}`).join('; ') + '; redact before saving'); process.exitCode = 1; return; }
855
+ if (!isGitClone()) { console.log('SESSION: NOT_GIT — session written locally only; run `npx toga-ai` to create a pushable clone'); return; }
856
+ const rel = 'knowledge/sessions/' + path.basename(abs);
857
+ gitRun(`git add "${rel}"`);
858
+ reportSession(pushStaged({ msg: args.msg || ('session: ' + data.slug), branch: args.branch, restrictPrefix: 'knowledge/sessions/' }), args.branch);
859
+ }
860
+
861
+ function cmdSessionList(args) {
862
+ const rows = listSessionDocs(args.branch, true).map(e => {
863
+ const { data } = parseFrontmatter(e.content);
864
+ const updated = data.updated || data.created || '';
865
+ const age = ageDays(updated);
866
+ return {
867
+ id: e.id, date: updated, author: data.author || '', framework: data.framework || '',
868
+ repos: Array.isArray(data.repos) ? data.repos.join(' ') : (data.repos || ''),
869
+ client: data.client || '', title: data.title || '', age,
870
+ };
871
+ });
872
+ const staleDays = args.stale !== undefined && args.stale !== true ? parseInt(args.stale, 10) : 14;
873
+ const mine = args.mine ? (typeof args.mine === 'string' ? args.mine : currentAuthor()) : null;
874
+ const enriched = rows
875
+ .map(r => ({ ...r, stale: (Number.isInteger(staleDays) && r.age !== null && r.age > staleDays) }))
876
+ .filter(r => !mine || r.author === mine)
877
+ .sort((a, b) => String(b.date).localeCompare(String(a.date)));
878
+ if (args.json) { console.log(JSON.stringify(enriched)); return; }
879
+ if (!enriched.length) { console.log('No team sessions saved yet.'); return; }
880
+ console.log(padTo('', 6) + ' ' + padTo('DATE', 10) + ' ' + padTo('AUTHOR', 12) + ' ' + padTo('AGE', 5) + ' ' + padTo('ID', 40) + ' TASK');
881
+ for (const r of enriched) {
882
+ console.log(
883
+ padTo(r.stale ? 'STALE' : 'ok', 6) + ' ' + padTo(r.date, 10) + ' ' + padTo(clip(r.author, 12), 12) + ' ' +
884
+ padTo((r.age === null ? '?' : r.age + 'd'), 5) + ' ' + padTo(clip(r.id, 40), 40) + ' ' + clip(r.title, 60)
885
+ );
886
+ }
887
+ }
888
+
889
+ function cmdSessionShow(args) {
890
+ const id = args.id || (args._ && args._[0]);
891
+ if (!id) return fail('session-show requires an id (filename without .md)');
892
+ const match = listSessionDocs(args.branch, true).find(e => e.id === id || e.id.includes(String(id)));
893
+ if (!match) { console.log('SESSION: NOT_FOUND — no session matching "' + id + '" (try: session-list)'); process.exitCode = 1; return; }
894
+ process.stdout.write(match.content);
895
+ }
896
+
897
+ function cmdSessionDelete(args) {
898
+ const id = args.id || (args._ && args._[0]);
899
+ if (!id) return fail('session-delete requires an id');
900
+ if (!isGitClone()) { console.log('SESSION: NOT_GIT — cannot delete a team session without a git clone'); process.exitCode = 1; return; }
901
+ const br = args.branch || '_main';
902
+ fetchQuiet();
903
+ const rel = 'knowledge/sessions/' + id + '.md';
904
+ // make sure the file is present in the working tree so git rm can stage its removal
905
+ try { gitRun(`git checkout origin/${br} -- "${rel}"`); } catch { /* may already be local or absent */ }
906
+ if (!fs.existsSync(path.join(__dirname, rel))) { console.log('SESSION: NOT_FOUND — no session "' + id + '" on origin/' + br); process.exitCode = 1; return; }
907
+ gitRun(`git rm -q "${rel}"`);
908
+ const st = pushStaged({ msg: args.msg || ('session: delete ' + id), branch: br, restrictPrefix: 'knowledge/sessions/' });
909
+ reportSession(st === 'PUSHED' ? 'DELETED' : st, br);
910
+ }
911
+
912
+ /* ------------------------------------------------------------------ */
913
+ /* command: skills — generated, always-current listing of team skills */
914
+ /* (the README table drifts; skills frontmatter is the source of truth).*/
915
+ /* --installed=<.claude/skills dir> marks installed/available/local-only.*/
916
+ /* ------------------------------------------------------------------ */
917
+
918
+ function cmdSkills(args) {
919
+ const skillsSrc = path.join(__dirname, 'skills');
920
+ const teamSkills = fs.existsSync(skillsSrc)
921
+ ? fs.readdirSync(skillsSrc, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name) : [];
922
+ const meta = {};
923
+ for (const s of teamSkills) {
924
+ const skf = path.join(skillsSrc, s, 'SKILL.md');
925
+ let name = s, desc = '';
926
+ if (fs.existsSync(skf)) { const { data } = parseFrontmatter(fs.readFileSync(skf, 'utf8')); name = data.name || s; desc = data.description || ''; }
927
+ meta[s] = { name, desc };
928
+ }
929
+ let installed = null;
930
+ if (args.installed && typeof args.installed === 'string') {
931
+ const dir = path.resolve(args.installed);
932
+ installed = fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name) : [];
933
+ }
934
+ const rows = teamSkills.map(s => ({
935
+ skill: s, name: meta[s].name, description: meta[s].desc,
936
+ status: installed ? (installed.includes(s) ? 'installed' : 'available') : 'team',
937
+ }));
938
+ if (installed) for (const s of installed) if (!teamSkills.includes(s)) rows.push({ skill: s, name: s, description: '(local only — not in team repo)', status: 'local-only' });
939
+ rows.sort((a, b) => a.skill.localeCompare(b.skill));
940
+ if (args.json) { console.log(JSON.stringify(rows)); return; }
941
+ console.log(padTo('STATUS', 11) + ' ' + padTo('SKILL', 22) + ' DESCRIPTION');
942
+ for (const r of rows) console.log(padTo(r.status, 11) + ' ' + padTo(clip(r.skill, 22), 22) + ' ' + clip(r.description, 90));
943
+ if (installed && rows.some(r => r.status === 'available')) console.log('\nRun /sync-team-skills to install the "available" skills.');
944
+ }
945
+
726
946
  /* ------------------------------------------------------------------ */
727
947
  /* dispatch */
728
948
  /* ------------------------------------------------------------------ */
@@ -730,6 +950,7 @@ function cmdValidate() {
730
950
  function main() {
731
951
  const [, , cmd, ...rest] = process.argv;
732
952
  const args = parseArgs(rest);
953
+ args._ = rest.filter(a => !a.startsWith('--'));
733
954
  switch (cmd) {
734
955
  case 'search': return cmdSearch(args);
735
956
  case 'index': return cmdIndex();
@@ -738,8 +959,13 @@ function main() {
738
959
  case 'manifest': return cmdManifest();
739
960
  case 'kickoff-preflight': return cmdPreflight(args);
740
961
  case 'publish': return cmdPublish(args);
962
+ case 'session-save': return cmdSessionSave(args);
963
+ case 'session-list': return cmdSessionList(args);
964
+ case 'session-show': return cmdSessionShow(args);
965
+ case 'session-delete': return cmdSessionDelete(args);
966
+ case 'skills': return cmdSkills(args);
741
967
  default:
742
- console.log('Usage: node knowledge.js <search|index|deps|validate|manifest|kickoff-preflight|publish> [--flags]');
968
+ console.log('Usage: node knowledge.js <search|index|deps|validate|manifest|kickoff-preflight|publish|session-save|session-list|session-show|session-delete|skills> [--flags]');
743
969
  process.exitCode = 1;
744
970
  }
745
971
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.168",
3
+ "version": "1.0.170",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",
@@ -24,6 +24,32 @@ function checkUnfinishedSession(cwd) {
24
24
  return fs.existsSync(sessionFile);
25
25
  }
26
26
 
27
+ // Resolve the team `claude` repo (same order kickoff uses), or null.
28
+ function resolveTeamRepo() {
29
+ const candidates = [];
30
+ if (process.env.CLAUDE_TEAM_REPO) candidates.push(process.env.CLAUDE_TEAM_REPO);
31
+ const home = process.env.HOME || process.env.USERPROFILE || '';
32
+ if (home) candidates.push(path.join(home, 'toga-tech'), path.join(home, 'claude'));
33
+ for (const dir of candidates) {
34
+ try { if (dir && fs.existsSync(path.join(dir, 'knowledge', 'registry.json'))) return dir; } catch { /* ignore */ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ // Best-effort, time-boxed peek at team sessions. Never throws; returns [] on any
40
+ // failure (offline, no clone, slow fetch) so startup stays fast and quiet.
41
+ function teamSessions(teamRepo) {
42
+ if (!teamRepo) return [];
43
+ try {
44
+ const out = require('child_process').execSync(
45
+ 'node "' + path.join(teamRepo, 'knowledge.js') + '" session-list --json',
46
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3500 }
47
+ );
48
+ const rows = JSON.parse(out);
49
+ return Array.isArray(rows) ? rows : [];
50
+ } catch { return []; }
51
+ }
52
+
27
53
  function main() {
28
54
  const cwd = process.cwd();
29
55
  const now = new Date().toLocaleDateString('en-US', {
@@ -48,6 +74,18 @@ function main() {
48
74
  console.log('');
49
75
  }
50
76
 
77
+ // Surface team-shared sessions (best-effort, time-boxed — never blocks startup).
78
+ const teamRepo = resolveTeamRepo();
79
+ const sessions = teamSessions(teamRepo);
80
+ if (sessions.length) {
81
+ const newest = sessions[0];
82
+ const fresh = sessions.filter(s => !s.stale).length;
83
+ console.log(`${sessions.length} team session(s) available${fresh < sessions.length ? ` (${sessions.length - fresh} stale)` : ''}.`);
84
+ console.log(` Newest: ${newest.id} by ${newest.author || 'unknown'} — "${newest.title || ''}"`);
85
+ console.log(' Run /session-resume <id> to continue one, or /session-resume list to see all.');
86
+ console.log('');
87
+ }
88
+
51
89
  console.log('Ready. Run /kickoff to prime context for your work session.');
52
90
  console.log('');
53
91
 
@@ -9,55 +9,70 @@ You are restoring context from a prior session so that work can continue without
9
9
  information loss. The briefing you provide must be actionable and explicit about what
10
10
  NOT to do — failed approaches from prior sessions are as important as what worked.
11
11
 
12
+ Sessions are **shared across the team**: they live in the team `claude` repo under
13
+ `knowledge/sessions/` and are pulled fresh on every resume, so you can pick up a session a
14
+ teammate saved (not just your own).
15
+
12
16
  ## Arguments
13
17
 
14
18
  ```
15
- /session-resume [name | "latest"]
19
+ /session-resume [latest | <slug-or-id> | list | delete <id>]
16
20
  ```
17
21
 
18
- - `name` — the exact session name slug (e.g. `auth-refactor`). Matches against the
19
- filename: `~/.claude/session-data/*-<name>-session.md`.
20
- - `latest` — loads the most recently created session file (newest by filename date).
21
- - If omitted, list available sessions and ask the developer which to load.
22
-
23
- ---
22
+ - `latest` — load the most recently updated team session.
23
+ - `<slug-or-id>` — load by slug (e.g. `auth-refactor`) or full id
24
+ (`2026-06-08-auth-refactor-asmith`).
25
+ - `list` — show all team sessions (with author + age + stale flags) and stop.
26
+ - `delete <id>` — remove a stale team session (confirm first), then stop.
27
+ - If omitted, list and ask which to load.
24
28
 
25
- ## Step 1 — Discover available sessions
29
+ First resolve `TEAM_REPO` exactly as `kickoff`/`session-save` do (the `team-repo-path`
30
+ memory, else `$CLAUDE_TEAM_REPO`, else probe `~/toga-tech` … for `knowledge/registry.json`).
31
+ All commands below run `node "<TEAM_REPO>/knowledge.js" …`, which **git-fetches first** so
32
+ teammates' just-pushed sessions are visible.
26
33
 
27
- Read the `~/.claude/session-data/` directory. If it does not exist or is empty:
34
+ ---
28
35
 
29
- ```
30
- No saved sessions found in ~/.claude/session-data/
36
+ ## Step 1 — Discover available sessions (always fetches latest)
31
37
 
32
- To save a session at the end of your current work: /session-save [name]
38
+ ```bash
39
+ node "<TEAM_REPO>/knowledge.js" session-list
33
40
  ```
34
41
 
35
- Then stop.
36
-
37
- Otherwise, list sessions sorted **newest first** (by the `YYYY-MM-DD` prefix):
42
+ This prints a table (`STATUS DATE AUTHOR AGE ID TASK`), newest first, flagging sessions
43
+ older than 14 days as `STALE`. Add `--mine=<author>` to filter to your own, `--stale=<days>`
44
+ to change the threshold. If it prints "No team sessions saved yet.", say so and stop:
38
45
 
39
46
  ```
40
- Available sessions (newest first):
41
- 1. 2026-06-08 — auth-refactor ~/.claude/session-data/2026-06-08-auth-refactor-session.md
42
- 2. 2026-06-07 — worker-cron-fix ~/.claude/session-data/2026-06-07-worker-cron-fix-session.md
43
- 3. 2026-06-05 — api2-pagination ~/.claude/session-data/2026-06-05-api2-pagination-session.md
47
+ No team sessions saved yet. Save one at the end of your work: /session-save [name]
44
48
  ```
45
49
 
50
+ **`list` mode:** render this table and stop here.
51
+
52
+ **`delete <id>` mode:** confirm with the developer, then run
53
+ `node "<TEAM_REPO>/knowledge.js" session-delete <id> --msg="session: retire <id>"`,
54
+ report the `SESSION: DELETED|NOT_FOUND|CONFLICT` result, and stop.
55
+
46
56
  ---
47
57
 
48
58
  ## Step 2 — Resolve which session to load
49
59
 
50
- - If `latest` was specified, take the newest file.
51
- - If a name was specified, find the file whose filename contains that name slug.
52
- - If multiple files match, take the newest.
53
- - If none match, say: "No session found matching '<name>'. Available: [list names]."
54
- - If no argument was given, ask: "Which session should I load? (Enter a number or name)"
60
+ - `latest` → the top row (newest `updated`).
61
+ - `<slug-or-id>` → the row whose id equals it or contains the slug. If several match
62
+ (same slug, different authors/dates), show those candidates (date + author) and ask which.
63
+ - If none match: "No session found matching '<x>'. Available: [list ids]."
64
+ - No argument → ask: "Which session should I load? (id or slug)"
55
65
 
56
66
  ---
57
67
 
58
68
  ## Step 3 — Load and parse the session file
59
69
 
60
- Read the session file. Parse these sections:
70
+ ```bash
71
+ node "<TEAM_REPO>/knowledge.js" session-show <id>
72
+ ```
73
+
74
+ This prints the full session file (frontmatter + body) from the freshest team state.
75
+ Parse these sections from the body:
61
76
  - **Task** — the one-sentence summary
62
77
  - **Project/Repo** — repo and framework
63
78
  - **Date** — when it was saved
@@ -77,8 +92,8 @@ Output a structured briefing in this exact format:
77
92
 
78
93
  ```
79
94
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
80
- Session Resume: <name>
81
- Saved: <YYYY-MM-DD> | Repo: <repo> (<framework>)
95
+ Session Resume: <slug>
96
+ Saved by <author> on <YYYY-MM-DD> | Repo: <repo> (<framework>)
82
97
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
83
98
 
84
99
  TASK
@@ -153,4 +168,8 @@ model accordingly.
153
168
  - If the "Exact next step" section is blank or unclear, ask the developer what they
154
169
  want to tackle before proceeding.
155
170
  - Recommend running `/session-save` again at the end of this session to update the
156
- record with new progress.
171
+ team record with new progress (re-saving the same slug overwrites and re-pushes it).
172
+ - You may be resuming a **teammate's** session — the briefing header names the author.
173
+ When you re-save, it saves under *your* author id, leaving theirs intact.
174
+ - Stale sessions accumulate: when `session-list` flags old ones you recognize as done,
175
+ retire them with `/session-resume delete <id>`.
@@ -67,30 +67,53 @@ when they resume — no thinking required.
67
67
 
68
68
  ---
69
69
 
70
- ## Step 2 — Determine save path and filename
70
+ ## Step 2 — Resolve the team repo, author, and save path
71
+
72
+ Sessions are **shared with the whole team by default** — saved into the team `claude`
73
+ repo and pushed, so any teammate can resume them (mirrors how `capture` publishes).
74
+
75
+ 1. **Team repo** — resolve `TEAM_REPO` the same way `kickoff` does: the `team-repo-path`
76
+ Claude memory first, else `$CLAUDE_TEAM_REPO`, else auto-probe (`~/toga-tech`, …) for a
77
+ dir containing `knowledge/registry.json`. Persist a `team-repo-path` memory if you had
78
+ to discover it.
79
+ 2. **Author** — the `author-username` Claude memory (the developer's KB username, e.g.
80
+ `jcardinal`). If absent, fall back to `git config user.name`.
81
+ 3. **Slug** — the Step 0 name (or a derived kebab-case slug, max 4 words).
71
82
 
72
83
  ```
73
- File: ~/.claude/session-data/YYYY-MM-DD-<name>-session.md
74
- Also: <project-root>/.session-latest.md (gitignored, for quick local reference)
84
+ Team file: <TEAM_REPO>/knowledge/sessions/YYYY-MM-DD-<slug>-<author>.md
75
85
  ```
76
86
 
77
- Where:
78
- - `YYYY-MM-DD` = today's date (use the current date from the session)
79
- - `<name>` = the session name from Step 0
80
- - `<project-root>` = the root of the project being worked on (use the `team-repo-path`
81
- memory or the current working directory; do not guess if unknown)
87
+ The `<author>` suffix lets two developers use the same slug without collision.
88
+ `YYYY-MM-DD` is today's date. Create `<TEAM_REPO>/knowledge/sessions/` if it does not exist.
82
89
 
83
- Create `~/.claude/session-data/` if it does not exist.
90
+ **No git clone / TEAM_REPO unresolved →** fall back to the legacy local-only path
91
+ `~/.claude/session-data/YYYY-MM-DD-<slug>-session.md` (create the dir if needed), warn the
92
+ developer that the session was saved **locally only** (no team push possible without a
93
+ clone — `npx toga-ai` creates one), and skip Step 4.
84
94
 
85
95
  ---
86
96
 
87
- ## Step 3 — Write the session file
97
+ ## Step 3 — Write the session file (frontmatter + body)
88
98
 
89
- Write the file at both paths using this exact template. Do not reformat, summarize,
90
- or condense — write the full content at both locations.
99
+ Write the team file using this exact template — **frontmatter first**, then the unchanged
100
+ 9-section body. Do not reformat, summarize, or condense.
91
101
 
92
102
  ```markdown
93
- # Session: <name>
103
+ ---
104
+ type: session
105
+ slug: <slug>
106
+ title: <short human title from 1a>
107
+ author: <author>
108
+ repos: [<repo>, <repo>] # repos touched this session
109
+ framework: "1.0" | "2.0" | "both" | "standalone"
110
+ client: <client-slug> | shared
111
+ status: active # set to "stale" later if abandoned
112
+ created: YYYY-MM-DD
113
+ updated: YYYY-MM-DD
114
+ ---
115
+
116
+ # Session: <slug>
94
117
  **Date:** YYYY-MM-DD
95
118
  **Project/Repo:** <repo> (<framework>)
96
119
  **Task:** <one-sentence summary from 1a>
@@ -126,17 +149,39 @@ or condense — write the full content at both locations.
126
149
  _Saved by /session-save on YYYY-MM-DD_
127
150
  ```
128
151
 
152
+ > **Never paste credentials into the body** (tokens, keys, passwords). The publisher
153
+ > secret-scans the file and refuses to push if it finds one. Reference the config/constant
154
+ > location instead.
155
+
129
156
  ---
130
157
 
131
- ## Step 4 — Confirm to developer
158
+ ## Step 4 — Push to the team (skip if Step 2 fell back to local-only)
159
+
160
+ Run the deterministic publisher — it validates the session frontmatter, secret-scans, and
161
+ does the rebase-before-push to `_main` (3× retry for the CI version-bump race), staging
162
+ **only** the session file:
163
+
164
+ ```bash
165
+ node "<TEAM_REPO>/knowledge.js" session-save --file="<the team file path>" --msg="session: <slug> — <task>"
166
+ ```
167
+
168
+ Read the single `SESSION:` line and act:
169
+ - `PUSHED to _main` — done.
170
+ - `NO_CHANGES` — file identical to what's already pushed; nothing to do.
171
+ - `INVALID …` / `SECRET …` — fix the frontmatter / redact the value, then re-run.
172
+ - `CONFLICT` — a real merge conflict; resolve manually in `<TEAM_REPO>`, then re-run.
173
+ - `NOT_GIT` — no clone; the file is saved locally only (treat as the Step 2 fallback).
174
+ - `PUSH_FAILED` — after 3 retries; run the manual push command it prints.
175
+
176
+ ---
132
177
 
133
- After writing both files, confirm:
178
+ ## Step 5 — Confirm to developer
134
179
 
135
180
  ```
136
- ✓ Session saved: ~/.claude/session-data/YYYY-MM-DD-<name>-session.md
137
- ✓ Latest copy: <project-root>/.session-latest.md
181
+ ✓ Session saved to team: knowledge/sessions/YYYY-MM-DD-<slug>-<author>.md (pushed to _main)
138
182
 
139
- To resume: /session-resume <name> (or: /session-resume latest)
183
+ To resume (from any machine): /session-resume <slug> (or: /session-resume latest)
184
+ See all team sessions: /session-resume list
140
185
 
141
186
  Session captured:
142
187
  • What worked: <N items>
@@ -144,15 +189,16 @@ Session captured:
144
189
  • Next step: <exact next step summary>
145
190
  ```
146
191
 
147
- If either write fails, report the error with the full path that failed and the reason.
192
+ If the save fell back to local-only, say so plainly and give the local path instead.
148
193
 
149
194
  ---
150
195
 
151
196
  ## Notes
152
197
 
153
- - Both files must be written. If the project root cannot be determined, write
154
- only to `~/.claude/session-data/` and note that the local copy was skipped.
155
- - The `.session-latest.md` file is gitignored (see `.gitignore`). It exists for
156
- quick local reference when the developer returns to the project the next day.
157
- - Do NOT compress or summarize the "What did NOT work" section. Future sessions
158
- depend on exact failure reasons to avoid repeating dead ends.
198
+ - **Team-by-default.** The whole point is cross-developer handoff: one dev saves, another
199
+ resumes. Local-only is just the offline/no-clone fallback.
200
+ - Do NOT compress or summarize the "What did NOT work" section. Future sessions depend on
201
+ exact failure reasons to avoid repeating dead ends.
202
+ - The session file lives under `knowledge/sessions/`, which `knowledge.js validate`/`index`
203
+ deliberately ignore — sessions are not knowledge docs and have their own listing
204
+ (`/session-resume list`).
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: team-skills
3
+ description: Lists every skill available in the team `claude` knowledge repo with its description, and marks which are installed in this project, available to pull, or local-only. Use when the developer says "what skills are available", "list team skills", "team skills", "show skills", "what can you do", or asks which team skills exist. Pairs with /sync-team-skills, which installs them.
4
+ ---
5
+
6
+ # Team Skills — discover what the team knowledge base offers
7
+
8
+ `/sync-team-skills` *installs* skills but there's no way to *see* what exists first. This
9
+ skill renders an always-current listing straight from each skill's `SKILL.md` frontmatter
10
+ (the README skill table is hand-maintained and drifts — do not use it as the source).
11
+
12
+ ## Step 1 — Resolve the team repo
13
+
14
+ Resolve `TEAM_REPO` exactly as `kickoff` does: the `team-repo-path` Claude memory first,
15
+ else `$CLAUDE_TEAM_REPO`, else probe (`~/toga-tech`, …) for a dir containing
16
+ `knowledge/registry.json`. Persist a `team-repo-path` memory if you had to discover it.
17
+
18
+ Optionally `git -C "<TEAM_REPO>" pull --rebase --autostash origin _main` first so the list
19
+ reflects skills teammates just pushed. Skip the pull silently if it is not a clone or
20
+ network is unavailable.
21
+
22
+ ## Step 2 — List skills with install status
23
+
24
+ ```bash
25
+ node "<TEAM_REPO>/knowledge.js" skills --installed="<project-root>/.claude/skills"
26
+ ```
27
+
28
+ `<project-root>` is the current working directory's project root (the dir containing
29
+ `.claude`). This prints a table `STATUS SKILL DESCRIPTION` where STATUS is one of:
30
+
31
+ - **installed** — present in this project's `.claude/skills/`; ready to use now.
32
+ - **available** — in the team repo but not installed here → run `/sync-team-skills`.
33
+ - **local-only** — installed locally but absent from the team repo (a personal/unpushed
34
+ skill, e.g. `worker2-action`). Flag it so the developer can decide whether to contribute it.
35
+
36
+ ## Step 3 — Present and advise
37
+
38
+ Render the table grouped by status (installed first, then available, then local-only). Then:
39
+
40
+ - If any row is **available**, tell the developer: "Run `/sync-team-skills` to install these."
41
+ - If any row is **local-only**, note it: "`<skill>` exists only on this machine — if it's
42
+ useful to the team, add it to `<TEAM_REPO>/skills/` and push."
43
+ - Keep descriptions to one line each; the full skill prose lives in its `SKILL.md`.
44
+
45
+ ## Notes
46
+
47
+ - This is read-only discovery. It never installs or modifies skills — that's
48
+ `/sync-team-skills`'s job.
49
+ - The canonical skill source is `<TEAM_REPO>/skills/*/SKILL.md` (what `sync-skills.js`
50
+ copies from), not the repo's `.claude/skills/` (an untracked local install).