Module 1 — Gather (Apps Script)
Office Hours: Session 12 — Thursday, 5 November 2026
Session goal: understand how one Apps Script step reads your calendar, email, notes, and Drive — and turns every rule with one right answer into code.
Bring: the CoS Gather project created and testGather run once (Part 1 below).
What this step does
- Reads your calendar (past day + upcoming), your inbox, Gemini notes emails, Drive files you own, company holidays, and time off.
- Decides facts in code: who organized each meeting (and the minimum RACI that follows), the previous occurrence of recurring meetings (including renamed or split series), which notes belong to which meeting, which action items are yours, and what's noise.
- Gives every item a short ref (E3, M1, N2). The URLs stay in a link map that only code sees.
- Outputs
gatherJson(for the AI steps),linkMapJson(for CoS Send), andgatherSummary.
Part 1 — Create and test the project
- Go to script.google.com and click New project.
- Click Untitled project at the top and rename it CoS Gather.
- Click the gear icon (Project Settings) and check "Show 'appsscript.json' manifest file in editor."
- Click the
< >icon (Editor). You now seeCode.gsandappsscript.json. - Click
Code.gs, select everything (Ctrl+A), delete it, and paste the Code.gs block below. - Click
appsscript.json, select everything, delete it, and paste the manifest block below. - Click Save (Ctrl+S). If a red error appears, the paste was incomplete — repeat steps 5–6.
- Pick
testGatherfrom the function dropdown and click Run. - Click Review permissions, choose your account.
- If you see "Google hasn't verified this app," click Advanced → Go to CoS Gather (unsafe) → Allow. It's your own script reading your own data.
- Check the Execution log: meetings, emails kept, and a CHECKS / ERRORS list with no errors.
Part 2 — Make it a Studio step
- Click Deploy → Test deployments (not "New deployment").
- Under Google Workspace Add-on, click Install, then Done. No Install button? Reload the browser page.
- In Workspace Studio, refresh — CoS Gather now appears as a step.
In the session
- Live debug: a renamed recurring meeting gets a new series ID, and a "monthly" meeting slipped to 36 days — how the code finds the previous occurrence anyway.
- Why refs: a model told never to touch links changed one character in a calendar URL. Refs make that impossible.
- Stretch: add your own noise rule to
CONFIG.NOISE_SUBJECTand reruntestGather.
Code — appsscript.json
{
"timeZone": "America/New_York",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"addOns": {
"common": {
"name": "CoS Gather",
"logoUrl": "https://www.gstatic.com/images/branding/productlogos/buganizer/v1/192px.svg",
"useLocaleFromApp": true
},
"studio": {
"flows": {
"workflowElements": [
{
"id": "cosGather",
"state": "ACTIVE",
"name": "CoS Gather",
"description": "Collects calendar, email, Gemini notes, Drive activity, holidays and time off as JSON for the Chief of Staff pipeline.",
"workflowAction": {
"inputs": [],
"outputs": [
{ "id": "gatherJson", "description": "Full gathered data as JSON", "cardinality": "SINGLE", "dataType": { "basicType": "STRING" } },
{ "id": "linkMapJson", "description": "Ref-to-URL map used by CoS Send", "cardinality": "SINGLE", "dataType": { "basicType": "STRING" } },
{ "id": "gatherSummary", "description": "Counts, checks and errors from the run", "cardinality": "SINGLE", "dataType": { "basicType": "STRING" } }
],
"onConfigFunction": "onConfigGather",
"onExecuteFunction": "onExecuteGather"
}
}
]
}
}
}
}
Code — Code.gs
/**
* CoS Gather — Workspace Studio step for the Chief of Staff pipeline.
* Reads your calendar, email, Gemini notes, Drive, Scotts holidays and time off.
* Outputs: gatherJson (for the AI steps, no URLs), linkMapJson (ref → URL, for CoS Send), gatherSummary.
* Editor test: run testGather and read the log.
*/
const CONFIG = {
UPCOMING_DAYS: 1, // test: 1. Production: 7
LOOKAHEAD_DAYS: 14, // holidays + time off
PRIOR_LOOKBACK_DAYS: 45, // monthly meetings can slip past 5 weeks
MAX_EMAIL_THREADS: 40, // fetched; noise is removed after
MAX_NOTES_THREADS: 100,
MAX_DRIVE_FILES: 20,
MAX_RECAP_THREADS: 3,
DESC_CHARS: 800,
BODY_CHARS: 500,
NOTES_CHARS: 1000, // notes summary; action items are kept separately
CUTOFF_HOUR: 15, // the "3 PM" rule
NOTES_SENDER: '[email protected]',
// Noise rules — fixed formats, so code handles them, not the Gem
NOISE_SUBJECT: /^(invitation|updated invitation|accepted|declined|tentatively accepted|canceled event|cancelled event|event canceled|new event|out of office|automatic reply|auto[- ]?reply)\b/i,
NOISE_SENDER: /(calendar-notification@google\.com|no-?reply|notifications?@|mailer-daemon|@.*atlassian\.net|jira@)/i,
KEEP_SUBJECT: /messaged you on Google Chat/i, // overrides the noise rules
// Your name as an action-item owner in Gemini notes is taken from your email address
// ([email protected] → "Jane Doe"). Add other spellings here if your notes use them, e.g. ['Janie Doe'].
USER_ALIASES: [],
// Company holidays (from the Holiday & Payroll Calendar). Update yearly.
// Cultural Day is associate's choice, so it isn't listed.
SCOTTS_HOLIDAYS: [
['2026-01-01', "New Year's Day"], ['2026-01-19', 'Martin Luther King, Jr. Day'],
['2026-05-25', 'Memorial Day'], ['2026-07-02', 'Independence Day'], ['2026-07-03', 'Independence Day'],
['2026-09-07', 'Labor Day'], ['2026-09-11', 'Patriot Day'],
['2026-11-26', 'Thanksgiving'], ['2026-11-27', 'Thanksgiving'],
['2026-12-23', 'Christmas'], ['2026-12-24', 'Christmas'], ['2026-12-25', 'Christmas'],
['2027-01-01', "New Year's Day"], ['2027-01-18', 'Martin Luther King, Jr. Day'],
['2027-02-15', "President's Day"], ['2027-05-31', 'Memorial Day'], ['2027-07-05', 'Independence Day'],
['2027-09-06', 'Labor Day'], ['2027-11-11', "Veteran's Day"],
['2027-11-25', 'Thanksgiving'], ['2027-11-26', 'Thanksgiving'],
['2027-12-23', 'Christmas'], ['2027-12-24', 'Christmas'], ['2027-12-27', 'Christmas'],
],
};
// ---------- entry points ----------
function testGather() {
const t0 = Date.now();
const res = gather(new Date());
res.data.meta.runtimeMs = Date.now() - t0;
Logger.log(summarize(res.data));
}
function gather(now) {
const me = Session.getActiveUser().getEmail(); // whoever runs the flow
const cal = CalendarApp.getDefaultCalendar();
const tz = cal.getTimeZone(); // that person's own time zone
const calId = cal.getId();
const mode = computeMode(now, tz);
const errors = [];
const checks = [];
const timings = {};
const skipped = { personalEvents: 0, noiseEmails: 0, driveNotOwned: 0 };
// ref registry: the only place URLs are kept
const linkMap = {};
const counters = {};
const ref = (prefix, url) => {
counters[prefix] = (counters[prefix] || 0) + 1;
const r = prefix + counters[prefix];
linkMap[r] = url || null;
return r;
};
const safe = (label, fn, fallback) => {
const t = Date.now();
const key = label.split(':')[0];
try { return fn(); } catch (e) { errors.push(`${label}: ${e.message}`); return fallback; }
finally { timings[key] = (timings[key] || 0) + (Date.now() - t); }
};
const summaryEvents = safe('summaryEvents', () => getEvents(cal, mode.summaryStart, mode.summaryEnd, tz, skipped, ref), []);
const upcomingEvents = safe('upcomingEvents', () => getEvents(cal, mode.upcomingStart, mode.upcomingEnd, tz, skipped, ref), []);
const lookbackStart = addDays(mode.upcomingStart, -CONFIG.PRIOR_LOOKBACK_DAYS, tz);
const notesIndex = safe('notesIndex', () => buildNotesIndex(lookbackStart, now), []);
summaryEvents.forEach(ev => {
ev.geminiNotes = safe('notesMatch', () => matchNotes(notesIndex, ev.title, ev.start, tz, ref), []);
});
const pastRaw = safe('pastEvents', () => cal.getEvents(lookbackStart, mode.upcomingStart), []);
const pastSnap = safe('pastSnapshot', () => pastRaw.map(e => ({
e, id: e.getId(), base: baseSeriesId(e.getId()), start: e.getStartTime(), titleKey: norm(e.getTitle()),
})), []);
upcomingEvents.filter(ev => ev.isRecurring).forEach(ev => {
ev.priorInstance = safe(`prior:${ev.title}`, () => getPriorInstance(ev, pastSnap, calId, tz, notesIndex, ref), null);
ev.recapEmails = safe(`recaps:${ev.title}`, () => findRecapEmails(ev.title, ref), []);
});
// Facts code can decide from the notes: which notes belong to each meeting,
// which action items are yours, and the CONSULTED floor that follows from them
const myNames = userNames(me);
summaryEvents.concat(upcomingEvents).forEach(ev => safe('notesFacts', () => applyNotesFacts(ev, myNames), null));
const emails = safe('emails', () => getEmails(mode.summaryStart, mode.summaryEnd, me, skipped, ref), []);
const driveFiles = safe('drive', () => getDriveFiles(mode.summaryStart, mode.summaryEnd, me, skipped, ref), []);
const lookEnd = addDays(mode.today, CONFIG.LOOKAHEAD_DAYS, tz);
const holidays = safe('holidays', () => getHolidays(mode.today, lookEnd, tz), null);
const timeOff = safe('timeOff', () => getTimeOff(cal, mode.today, lookEnd, calId, tz, ref), []);
// ---- test checks ----
summaryEvents.filter(e => !e.description && !e.geminiNotes.length)
.forEach(e => checks.push(`Past meeting, no description or Gemini notes: "${e.title}"`));
upcomingEvents.filter(e => !e.description && !e.isRecurring)
.forEach(e => checks.push(`Upcoming, no description: "${e.title}"`));
upcomingEvents.filter(e => e.isRecurring && !e.priorInstance)
.forEach(e => checks.push(`Recurring, prior occurrence not found: "${e.title}"`));
upcomingEvents.filter(e => e.priorInstance && e.priorInstance.matchType !== 'series')
.forEach(e => checks.push(`Recurring, prior found by ${e.priorInstance.matchType}: "${e.title}"`));
if (emails.length + skipped.noiseEmails >= CONFIG.MAX_EMAIL_THREADS) checks.push(`Email fetch cap hit (${CONFIG.MAX_EMAIL_THREADS}) — some threads skipped`);
if (notesIndex.length >= CONFIG.MAX_NOTES_THREADS) checks.push(`Notes cap hit (${CONFIG.MAX_NOTES_THREADS}) — older notes skipped`);
if (holidays && holidays.note) checks.push(holidays.note);
const missingLinks = Object.keys(linkMap).filter(k => !linkMap[k]);
if (missingLinks.length) checks.push(`Refs with no URL: ${missingLinks.join(', ')}`);
const data = {
meta: {
user: me, timezone: tz, generatedAt: now.toISOString(), today: fmt(now, tz, 'yyyy-MM-dd'),
summaryDate: fmt(mode.summaryStart, tz, 'yyyy-MM-dd'),
upcomingFrom: fmt(mode.upcomingStart, tz, 'yyyy-MM-dd'),
upcomingDays: CONFIG.UPCOMING_DAYS,
afterCutoff: mode.afterCutoff,
includeRetrospective: mode.retrospective,
refCount: Object.keys(linkMap).length,
skipped,
timingsMs: timings,
notesEmailsIndexed: notesIndex.length,
},
summaryDay: { events: summaryEvents, emails, driveFiles },
upcoming: { events: upcomingEvents },
lookahead: { holidays: holidays ? holidays.items : [], timeOff },
checks, errors,
};
return { data, linkMap };
}
// ---------- the deterministic rules from the Gem prompt ----------
function computeMode(now, tz) {
const hour = Number(fmt(now, tz, 'H'));
const dow = Number(fmt(now, tz, 'u')); // 1 = Mon ... 7 = Sun
const today = dayStart(now, tz);
const afterCutoff = hour >= CONFIG.CUTOFF_HOUR;
const summaryStart = afterCutoff ? today : prevBusinessDay(today, tz);
const upcomingStart = afterCutoff ? addDays(today, 1, tz) : today;
return {
today, afterCutoff,
summaryStart, summaryEnd: addDays(summaryStart, 1, tz),
upcomingStart, upcomingEnd: addDays(upcomingStart, CONFIG.UPCOMING_DAYS, tz),
retrospective: (dow === 5 && afterCutoff) || (dow === 1 && !afterCutoff),
};
}
function getEvents(cal, start, end, tz, skipped, ref) {
const calId = cal.getId();
return cal.getEvents(start, end)
.filter(e => !['WORKING_LOCATION', 'OUT_OF_OFFICE'].includes(eventType(e)))
.filter(e => String(e.getMyStatus()) !== 'NO') // drop meetings you declined
.filter(e => { // drop personal events (no other attendees)
if (e.getGuestList().length === 0) { skipped.personalEvents++; return false; }
return true;
})
.map(e => toEvent(e, calId, tz, ref));
}
function toEvent(e, calId, tz, ref) {
const iAmOrganizer = e.isOwnedByMe(); // the YES/NO check, from the calendar itself
const guests = e.getGuestList();
return {
ref: ref('E', eventLink(e, calId, tz)),
seriesKey: e.isRecurringEvent() ? e.getId() : null,
isRecurring: e.isRecurringEvent(),
title: e.getTitle() || '(no title)',
type: eventType(e),
start: e.isAllDayEvent() ? fmt(e.getAllDayStartDate(), tz, 'yyyy-MM-dd') : e.getStartTime().toISOString(),
end: e.isAllDayEvent() ? fmt(e.getAllDayEndDate(), tz, 'yyyy-MM-dd') : e.getEndTime().toISOString(),
when: e.isAllDayEvent() ? fmt(e.getAllDayStartDate(), tz, 'EEE MMM d') + ' (all day)' : fmt(e.getStartTime(), tz, 'EEE MMM d, h:mm a'),
organizer: e.getCreators().join(', '),
iAmOrganizer,
attendeeOnly: !iAmOrganizer,
myResponse: String(e.getMyStatus()), // OWNER / YES / MAYBE / INVITED
raciFloor: iAmOrganizer ? 'ACCOUNTABLE' : null, // hard rule, enforced here
attendeeCount: guests.length,
attendees: guests.slice(0, 15).map(g => g.getName() || g.getEmail()),
description: trunc(stripHtml(e.getDescription() || ''), CONFIG.DESC_CHARS),
};
}
function getPriorInstance(ev, pastSnap, calId, tz, notesIndex, ref) {
const start = new Date(ev.start);
const before = pastSnap.filter(s => s.start < start);
const notDeclined = s => String(s.e.getMyStatus()) !== 'NO'; // only called on candidates
// 1) same series ID 2) same base series ID ("this and following" edits) 3) same title + organizer
let matchType = 'series';
let prior = before.filter(s => s.id === ev.seriesKey).filter(notDeclined);
if (!prior.length) {
matchType = 'baseSeries';
const base = baseSeriesId(ev.seriesKey);
prior = before.filter(s => s.base === base).filter(notDeclined);
}
if (!prior.length) {
matchType = 'title+organizer';
prior = before.filter(s => s.titleKey === norm(ev.title))
.filter(s => s.e.getCreators().join(', ') === ev.organizer).filter(notDeclined);
}
if (!prior.length) return null;
prior.sort((a, b) => a.start - b.start);
const p = prior[prior.length - 1].e;
return {
ref: ref('P', eventLink(p, calId, tz)),
matchType,
start: p.getStartTime().toISOString(),
description: trunc(stripHtml(p.getDescription() || ''), CONFIG.DESC_CHARS),
geminiNotes: matchNotes(notesIndex, ev.title, p.getStartTime().toISOString(), tz, ref),
};
}
// Gemini notes emails: subject = Notes: “<meeting title>” Oct 19, 2026
function buildNotesIndex(start, end) {
const q = `from:${CONFIG.NOTES_SENDER} after:${Math.floor(start / 1000)} before:${Math.floor(end / 1000) + 86400}`;
const out = [];
GmailApp.search(q, 0, CONFIG.MAX_NOTES_THREADS).forEach(t => {
const subject = t.getFirstMessageSubject();
const m = subject.match(/^Notes:\s*[“"](.+?)[”"]\s+([A-Z][a-z]{2} \d{1,2}, \d{4})/);
if (!m) return;
out.push({ titleKey: norm(m[1]), date: m[2], subject, thread: t }); // body read only on match
});
return out;
}
function matchNotes(index, title, startIso, tz, ref) {
const d = /^\d{4}-\d{2}-\d{2}$/.test(startIso) ? new Date(startIso + 'T12:00:00') : new Date(startIso);
const dateKey = fmt(d, tz, 'MMM d, yyyy');
return index
.filter(n => n.titleKey === norm(title) && n.date === dateKey)
.map(n => {
if (!n.loaded) {
const body = n.thread.getMessages()[0].getPlainBody();
const parts = splitNotes(stripUrls(body));
n.loaded = {
ref: ref('N', threadLink(n.thread)),
subject: n.subject,
summary: trunc(parts.summary, CONFIG.NOTES_CHARS),
actionItems: parts.actionItems, // "Suggested next steps", one entry per item
};
}
return n.loaded;
});
}
function findRecapEmails(title, ref) {
const clean = title.replace(/"/g, '').trim();
if (!clean || clean === '(no title)') return [];
return GmailApp.search(`subject:"${clean}" -from:${CONFIG.NOTES_SENDER} newer_than:21d`, 0, CONFIG.MAX_RECAP_THREADS)
.filter(t => !CONFIG.NOISE_SUBJECT.test(t.getFirstMessageSubject()))
.filter(t => norm(t.getFirstMessageSubject()).includes(norm(clean))) // Gmail search is loose; require the title
.map(t => ({
ref: ref('R', threadLink(t)),
subject: t.getFirstMessageSubject(),
lastDate: t.getLastMessageDate().toISOString(),
matchType: 'subject', // weaker than series ID; Analyst should treat as a candidate, not proof
}));
}
function getEmails(start, end, me, skipped, ref) {
const q = `after:${Math.floor(start / 1000)} before:${Math.floor(end / 1000)} -category:promotions -category:social -from:${CONFIG.NOTES_SENDER} -subject:"CoS Briefing"`;
const meLc = me.toLowerCase();
const out = [];
GmailApp.search(q, 0, CONFIG.MAX_EMAIL_THREADS).forEach(t => {
const subject = t.getFirstMessageSubject();
const keep = CONFIG.KEEP_SUBJECT.test(subject);
if (!keep && CONFIG.NOISE_SUBJECT.test(subject)) { skipped.noiseEmails++; return; } // cheap check first
const msgs = t.getMessages();
const last = msgs[msgs.length - 1];
if (!keep && CONFIG.NOISE_SENDER.test(msgs[0].getFrom())) { skipped.noiseEmails++; return; }
const to = (last.getTo() || '').toLowerCase();
const cc = (last.getCc() || '').toLowerCase();
out.push({
ref: ref('M', threadLink(t)),
subject,
messageCount: msgs.length,
firstFrom: msgs[0].getFrom(),
lastFrom: last.getFrom(),
lastDate: last.getDate().toISOString(),
iParticipated: msgs.some(m => m.getFrom().toLowerCase().includes(meLc)), // attribution signal
addressedAs: to.includes(meLc) ? 'to' : (cc.includes(meLc) ? 'cc' : 'other'),
snippet: trunc(stripUrls(last.getPlainBody()).replace(/\s+/g, ' '), CONFIG.BODY_CHARS),
});
});
return out;
}
function getDriveFiles(start, end, me, skipped, ref) {
const q = `modifiedDate > '${start.toISOString()}' and modifiedDate < '${end.toISOString()}' and trashed = false`;
const it = DriveApp.searchFiles(q);
const out = [];
const meLc = me.toLowerCase();
while (it.hasNext() && out.length < CONFIG.MAX_DRIVE_FILES) {
const f = it.next();
const owner = f.getOwner();
if (!owner || owner.getEmail().toLowerCase() !== meLc) { skipped.driveNotOwned++; continue; } // yours only
out.push({
ref: ref('D', f.getUrl()),
name: f.getName(),
type: f.getMimeType(),
lastUpdated: f.getLastUpdated().toISOString(),
});
}
return out;
}
function getHolidays(start, end, tz) {
const from = fmt(start, tz, 'yyyy-MM-dd');
const to = fmt(end, tz, 'yyyy-MM-dd');
const list = CONFIG.SCOTTS_HOLIDAYS;
const last = list[list.length - 1][0];
return {
note: to > last ? `Scotts holiday list ends ${last} — update CONFIG.SCOTTS_HOLIDAYS` : null,
items: list.filter(([d]) => d >= from && d < to).map(([date, title]) => ({ date, title })),
};
}
function getTimeOff(cal, start, end, calId, tz, ref) {
return cal.getEvents(start, end)
.filter(e => eventType(e) === 'OUT_OF_OFFICE')
.map(e => ({ ref: ref('T', eventLink(e, calId, tz)), title: e.getTitle() || 'Out of office', start: e.getStartTime().toISOString(), end: e.getEndTime().toISOString() }));
}
// ---------- helpers ----------
function eventType(e) {
try { return String(e.getEventType()); } catch (err) { return 'DEFAULT'; }
}
// "[email protected]" → "abc"
function baseSeriesId(id) { return (id || '').replace(/@google\.com$/, '').replace(/_R\d{8}(T\d{6})?$/, ''); }
// Stable Gmail thread URL
function threadLink(t) { return 'https://mail.google.com/mail/u/0/#all/' + t.getId(); }
// CalendarApp has no event URL, so build the standard "eid" link: base64("<eventId> <calendarId>").
function eventLink(e, calId, tz) {
let id = e.getId().replace(/@google\.com$/, '');
if (e.isRecurringEvent()) {
id += '_' + (e.isAllDayEvent()
? fmt(e.getAllDayStartDate(), tz, 'yyyyMMdd')
: Utilities.formatDate(e.getStartTime(), 'UTC', "yyyyMMdd'T'HHmmss'Z'"));
}
const eid = Utilities.base64Encode(id + ' ' + calId).replace(/=+$/, '');
return 'https://calendar.google.com/calendar/event?eid=' + eid;
}
// Gemini notes email = header, "Quick Notes" summary, "Suggested next steps", footer.
// Split so action items are never lost to truncation.
function splitNotes(body) {
let text = body.replace(/\r/g, '');
const footer = text.search(/(You should review Gemini|Please provide feedback|Get tips and learn how Gemini|Google LLC)/i);
if (footer > 0) text = text.slice(0, footer);
const idx = text.search(/Suggested next steps/i);
const summaryRaw = idx >= 0 ? text.slice(0, idx) : text;
const actionsRaw = idx >= 0 ? text.slice(idx).replace(/^Suggested next steps\s*/i, '') : '';
const summary = summaryRaw
.replace(/^[\s\S]*?Quick Notes/i, '') // drop the header boilerplate
.replace(/\s+/g, ' ').trim();
const actionItems = actionsRaw
.split(/\n\s*\n|(?=\[[^\]]+\]\s)/) // items usually start with "[Owner Name]"
.map(s => s.replace(/\s+/g, ' ').trim())
.filter(s => s.length > 3)
.slice(0, 15)
.map(s => trunc(s, 300));
return { summary, actionItems };
}
const RANK = { INFORMED: 1, CONSULTED: 2, ACCOUNTABLE: 3, RESPONSIBLE: 4 };
// "[email protected]" → ["jane doe", ...aliases], normalized
function userNames(email) {
const fromEmail = email.split('@')[0].split(/[._-]/).join(' ');
return [fromEmail].concat(CONFIG.USER_ALIASES || []).map(norm);
}
function applyNotesFacts(ev, myNames) {
const notes = [].concat(ev.geminiNotes || [], (ev.priorInstance && ev.priorInstance.geminiNotes) || []);
ev.linkedNotes = notes.map(n => n.ref);
ev.myActionItems = [];
notes.forEach(n => (n.actionItems || []).forEach(a => {
const m = a.match(/^\[([^\]]+)\]\s*(.*)$/);
if (m && myNames.includes(norm(m[1]))) ev.myActionItems.push({ source: n.ref, item: m[2] });
}));
if (ev.raciFloor === 'ACCOUNTABLE') ev.raciFloorReason = 'user is the organizer';
if (ev.myActionItems.length && (RANK[ev.raciFloor] || 0) < RANK.CONSULTED) {
ev.raciFloor = 'CONSULTED';
ev.raciFloorReason = "user owns action items in this meeting's notes";
}
}
// What the AI steps see: same field names as before, minus URLs and diagnostics
function analystView(d) {
const ev = e => {
const out = {
ref: e.ref, title: e.title, when: e.when, start: e.start, end: e.end,
organizer: e.organizer, iAmOrganizer: e.iAmOrganizer, attendeeOnly: e.attendeeOnly,
myResponse: e.myResponse, raciFloor: e.raciFloor, raciFloorReason: e.raciFloorReason || null,
attendeeCount: e.attendeeCount, attendees: e.attendees, description: e.description,
linkedNotes: e.linkedNotes || [], myActionItems: e.myActionItems || [],
};
if (e.geminiNotes) out.geminiNotes = e.geminiNotes;
if (e.priorInstance) out.priorInstance = {
ref: e.priorInstance.ref, start: e.priorInstance.start, geminiNotes: e.priorInstance.geminiNotes };
if (e.recapEmails && e.recapEmails.length) out.recapEmails = e.recapEmails;
return out;
};
return {
meta: {
user: d.meta.user, today: d.meta.today, summaryDate: d.meta.summaryDate,
upcomingFrom: d.meta.upcomingFrom, includeRetrospective: d.meta.includeRetrospective,
},
summaryDay: { events: d.summaryDay.events.map(ev), emails: d.summaryDay.emails, driveFiles: d.summaryDay.driveFiles },
upcoming: { events: d.upcoming.events.map(ev) },
lookahead: d.lookahead, // for the Writer; the Analyst ignores it
};
}
function stripUrls(s) { return (s || '').replace(/https?:\/\/\S+/g, '[link]'); }
// Normalize titles for matching: curly quotes, dashes, whitespace, case
function norm(s) {
return (s || '').replace(/[“”]/g, '"').replace(/[‘’]/g, "'").replace(/[–—]/g, '-')
.replace(/\s+/g, ' ').trim().toLowerCase();
}
function summarize(o) {
const m = o.meta;
const up = o.upcoming.events;
const priors = up.filter(e => e.priorInstance);
return [
`CoS GATHER v8 — ${m.user} (${m.timezone}) — ${m.runtimeMs} ms`,
`Summary day: ${m.summaryDate} | Upcoming from: ${m.upcomingFrom} (+${m.upcomingDays}d) | After 3 PM: ${m.afterCutoff} | Retro: ${m.includeRetrospective}`,
`Refs: ${m.refCount} | Skipped — personal events: ${m.skipped.personalEvents}, noise emails: ${m.skipped.noiseEmails}, Drive not owned: ${m.skipped.driveNotOwned}`,
`TIMINGS (ms): ${Object.entries(m.timingsMs).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}=${v}`).join(' | ')}`,
`Summary-day events: ${o.summaryDay.events.length} (with notes: ${o.summaryDay.events.filter(e => e.geminiNotes.length).length}) | Emails kept: ${o.summaryDay.emails.length} | Drive files (yours): ${o.summaryDay.driveFiles.length}`,
`Upcoming events: ${up.length} (recurring: ${up.filter(e => e.isRecurring).length}, prior found: ${priors.length}, prior with notes: ${priors.filter(e => e.priorInstance.geminiNotes.length).length}, organizer: ${up.filter(e => e.iAmOrganizer).length})`,
`Emails kept: ${o.summaryDay.emails.map(e => e.subject).join(' | ')}`,
`CHECKS (${o.checks.length}):`, ...o.checks.map(c => ' - ' + c),
`ERRORS (${o.errors.length}):`, ...o.errors.map(e => ' - ' + e),
].join('\n');
}
function fmt(d, tz, p) { return Utilities.formatDate(d, tz, p); }
function dayStart(d, tz) { return new Date(fmt(d, tz, "yyyy-MM-dd'T'00:00:00XXX")); }
function addDays(d, n, tz) { return dayStart(new Date(d.getTime() + n * 864e5 + 6 * 36e5), tz); } // +6h absorbs DST shifts
function prevBusinessDay(today, tz) {
let d = addDays(today, -1, tz);
while (['6', '7'].includes(fmt(d, tz, 'u'))) d = addDays(d, -1, tz);
return d;
}
function trunc(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
function stripHtml(s) {
return s.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '')
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim();
}
// ---------- Workspace Studio custom step ----------
// Outputs: gatherJson (refs only, no URLs), linkMapJson (ref → URL), gatherSummary.
function onConfigGather() {
const section = CardService.newCardSection()
.setHeader('CoS Gather')
.addWidget(CardService.newTextParagraph().setText(
'Collects calendar, email, Gemini notes, Drive activity, holidays and time off for the person running the flow. No settings needed.'));
return CardService.newCardBuilder().addSection(section).build();
}
function onExecuteGather(event) {
const t0 = Date.now();
const res = gather(new Date());
res.data.meta.runtimeMs = Date.now() - t0;
const action = AddOnsResponseService.newReturnOutputVariablesAction().setVariableDataMap({
gatherJson: AddOnsResponseService.newVariableData().addStringValue(JSON.stringify(analystView(res.data))),
linkMapJson: AddOnsResponseService.newVariableData().addStringValue(JSON.stringify(res.linkMap)),
gatherSummary: AddOnsResponseService.newVariableData().addStringValue(summarize(res.data)),
});
return AddOnsResponseService.newRenderActionBuilder()
.setHostAppAction(AddOnsResponseService.newHostAppAction().setWorkflowAction(action))
.build();
}
Troubleshooting
| What you see | What to do |
|---|---|
| No Install button under Test deployments | Reload the browser page; check appsscript.json saved without a red error. |
| Studio: "Something went wrong with the authentication…" | Deploy → Test deployments → Uninstall, then Install. Refresh Studio. |
| Studio: "Script function not found" | A manifest lists a step whose code isn't in that project. Re-paste both files for that project from this page. |
| Turn on is greyed out | A step isn't fully set up — look for the step with a warning icon (often a missing variable). |
| Analyst says "No CoS data provided" | The prompt has typed text instead of the gatherJson variable. Re-insert it with the variable picker. |
| A step doesn't appear in Studio | Refresh Studio; confirm the test deployment is installed in that project. |
Build kit home · 1 Gather · 2 Analyst · 3 Writer · 4 Send · 5 Build your own