Skip to content

feat: add Slack as a source via Glean, skipped when unavailable - #54

Draft
Bhekani Khumalo (bhekanik) wants to merge 11 commits into
mainfrom
feat/m3-slack-source
Draft

feat: add Slack as a source via Glean, skipped when unavailable#54
Bhekani Khumalo (bhekanik) wants to merge 11 commits into
mainfrom
feat/m3-slack-source

Conversation

@bhekanik

@bhekanik Bhekani Khumalo (bhekanik) commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Activity is now recorded as timestamped events in a cache-side ledger, sources are plugins behind one interface, and worklog refresh picks up what has changed since the last run and rewrites only the weeks it belongs to.

The rule this serves

We keep the state of things as they were when we first fetched them. If there is a change later, we record it on the date the change happened: a new comment goes on the week of the comment's timestamp; a description change goes on the week it happened, or if we cannot know that, the week we spotted it. We can add things we missed in history, but we do not rewrite history.

Before this, a week was fetched as "how these items look right now". Regenerating an August week in September narrated August from September's state — a ticket closed last week read as finished work in a week where it was still in progress.

Ledger layout

$XDG_CACHE_HOME/worklog/ledger/     (~/.cache/worklog/ledger by default)
├── meta.json                  # version, and per source: fetchedAt, windows[], state{}
├── events/<weekId>.json       # append-only, sorted by (at, key)
└── snapshots/<source>.json    # id -> { id, firstSeenAt, payload }, written once per item

Why this shape:

  • Sharded by week, not by source. Reading or writing one week touches one file. refresh writes only the weeks whose event set changed, which is the property the whole command rests on.
  • Snapshots separate from events. A snapshot is written once and never again — it is what a week is entitled to show about an item. Everything after that is an event with its own date. That split is the history rule made structural rather than remembered.
  • A week is its events' timestamps. A comment written in September lands in September's file even though the ticket is from August. No source decides week membership.
  • In the cache dir, not the vault. Refetchable, large, and the vault is synced to iCloud and indexed by Obsidian. Deleting it costs a refetch.
  • JSON, parsed on read. A person can read and therefore edit these files, and a machine can lose power mid-write. Every row goes through zod on the way in; a row that no longer parses is dropped and the rest of the ledger still opens.
  • Idempotent recording. Event identity is the system's own id when there is one (source|id), else source|kind|itemId|at. Re-fetching a week matches instead of duplicating, files are only written when their contents differ, and a second run is byte-identical in both the vault and the cache — asserted in commands/__tests__/refresh.test.ts.

The frozen Source interface

lib/sdk/sources.ts, matching what M3 (#54) built against, extended only with optional fields:

export interface SourceSnapshot { id: string; firstSeenAt: string; payload: unknown; }

export interface SourceEvent {
  source: string; kind: string; itemId: string; at: string; payload: unknown; id?: string;
}

export interface SourceBatch {
  snapshots: SourceSnapshot[]; events: SourceEvent[]; warnings: string[];
}

export interface SourceState {
  get(key: string): string | undefined;
  set(key: string, value: string): void;
}

export interface SourceContext {
  config: WorklogConfig;
  log?: Logger;
  headers?: FetchHeaders;
  identity?: { atlassianAccountId: string; githubUsername: string };
  onWarning?: (message: string) => void;
  state?: SourceState;
}

export type SourceAvailability = { ok: true } | { ok: false; reason: string };
export interface SourceWindow { start: Date; end: Date; }

export interface Source {
  name: string;
  isAvailable(ctx: SourceContext): Promise<SourceAvailability>;
  fetchWindow(window: SourceWindow, ctx: SourceContext): Promise<SourceBatch>;
  fetchSince(since: Date, itemIds: string[], ctx: SourceContext): Promise<SourceBatch>;
}

Two questions and nothing else: what happened in this window (the expensive first look) and what has happened since this moment (the cheap one every later run asks).

worklog refresh

worklog refresh                    # every week since the current team started
worklog refresh --since 2026-01-01
worklog refresh --week 2026-W07
worklog refresh --source jira

Asks each source the delta question, files each answer in the week it happened, and rewrites only the weeks whose event set changed. A run that finds nothing makes no AI call and writes no file. A week that does need rewriting has its existing brag book sent back with only the events this run added, and the instruction is to add to the entry rather than replace it; the vault writers from #47 make re-applying what was already applied a no-op.

The closing table shows what each source contributed per week and how long each source took — Slack's Glean path takes minutes where the HTTP sources take seconds, and a user waiting deserves to know which.

Dating what has no date

A description edit has no timestamp of its own. It is dated at the moment it was found and its payload marked spotted: true; the work log then carries a ## Dating section saying so plainly, so the model is never quietly told a change happened on a day it did not.

Prompt

prompts/weekly-brag-prompt.md no longer tells the model to fetch the current status of a ticket and describe reality at generation time. It now says the week is a closed record, and generation_context states whether it is the current week or a past one.

Also

  • The weekly command generates from the ledger too, so a week written today and the same week amended next month are built the same way from the same events.
  • getWeekEnd returns Sunday at 00:00 — the start of the last day. Window ends are pushed to the last instant of Sunday in one place (weekWindow) rather than in each source.
  • allSources() is one list, read by both commands.

Tests

652 passing, 23 files. New: lib/sdk/__tests__/ledger.test.ts (28), lib/sdk/__tests__/source-adapters.test.ts (30), commands/__tests__/refresh.test.ts (6), plus 8 in markdown.test.ts. anti-slop-check --base origin/main is down to 3 findings, all no-unknown-parameters on the three functions whose job is to be the parsing boundary (keepParsable, writeJsonAtomic, renderable).


Review round 1 (all nine findings addressed)

Every CLI flag was verified against the installed claude --help (2.1.246). The relevant
help lines:

--tools <tools...>        Specify the list of available tools from the built-in set. Use ""
                          to disable all tools, "default" to use all tools, or specify tool
                          names (e.g. "Bash,Edit,Read").
--strict-mcp-config       Only use MCP servers from --mcp-config, ignoring all other MCP
                          configurations
--mcp-config <configs...> Load MCP servers from JSON files or strings (space-separated)
--json-schema <schema>    JSON Schema for structured output validation.
--output-format <format>  Output format (only works with --print): "text" (default), "json"
                          (single result), or "stream-json" (realtime streaming)
--permission-mode <mode>  (choices: "acceptEdits", "auto", "bypassPermissions", "manual",
                          "dontAsk", "plan")
  1. Tool restriction. Confirmed live that --allowedTools alone leaves the child with Bash,
    Read, WebFetch and every configured MCP server (Gmail, Playwright and others were all
    reachable). The invocation is now --tools "" plus --strict-mcp-config --mcp-config <glean only> plus --allowedTools for the three Glean tools. Verified live: the child's tool list
    drops to the eight mcp__glean_default__* tools and nothing else. The Glean server is read out
    of claude mcp get at runtime, so no company-specific URL is hardcoded, and the stored OAuth
    still works through the inline config. When the server is not http/sse it cannot be isolated,
    so the source reports itself unavailable rather than falling back to a wider child. The argv is
    asserted in tests.
  2. Author and visibility. author and channelType are required schema fields, the prompt
    tells the model to fill them from Glean metadata, and anything not written by
    profile.fullName/displayName or not in a public channel is dropped locally with a count in
    a warning. Identity comparison is on letters and digits only, so @first.last matches
    First Last. docs/setup.md says plainly this is best-effort on top of Glean's
    permission-aware index, not a guarantee.
  3. Opt-out and budget. --no-slack added (commander boolean, skips the availability probe
    too). One 240s deadline now covers the attempt and its retry; the retry gets only the time
    left and is skipped below 60s. --verbose prints the per-week Slack seconds.
  4. Permalinks. Must be https on slack.com or a subdomain. Link destinations are escaped
    before rendering.
  5. Status gate. The Status: line is parsed into a word list that must be exactly
    ["connected"]. Not Connected and Needs authentication are both tested and both rejected.
  6. README. The data-handling paragraph now says what reaches Claude Code and Glean when
    Slack is on, and how to turn it off.
  7. Structured output. --json-schema (derived from the zod schema, one source of truth) plus
    --output-format json; structured_output is read directly. The fallback scans for a
    balanced JSON object with a depth counter, so Found {one result}: {"messages":[]} parses —
    that exact case is a test.
  8. Timers. Slack has its own duration in WeekTiming, in the stats file, and as a
    Slack via Glean line in the summary breakdown. The API fetch timer stops before Slack.
  9. Flaky test. The byte-equality test freezes the clock with vi.setSystemTime.

Also from this round: the brag prompt says quoted message text is data and never instruction, and
the fetch prompt says the same to the child.

Live re-verification after the changes

The first run with the hardened argv failed in one second:
--json-schema is not a valid JSON Schema: no schema with key or ref "https://json-schema.org/draft/2020-12/schema". The CLI validates the schema and rejects the
$schema dialect ref zod emits, so it is stripped; there is a regression test. The next run went
through end to end: 169s, three messages kept, 0 not public, 0 not yours, 0 malformed, 0 outside the range, zero warnings, author and channelType correctly filled from Glean metadata.

Checks

bunx tsc --noEmit clean; bun run lint clean; bun run test 680 passing across 22 files.
Thirteen guards mutation-checked, each turning tests red when removed: the channelType check, the
author check, the permalink host check, the https check, the status word-list check, the shared
deadline, the retry's remaining-time budget, --strict-mcp-config, --tools "", the $schema
strip, the balanced scan, per-item parsing, and link escaping.

One deliberate collision

anti-slop-check flags the vi.mock added to commands/__tests__/worklog.test.ts. That file
already mocks the AI, the network, the vault writer and clack the same way (six pre-existing
findings). The stub is needed because runWorklog would otherwise spawn the Claude Code CLI from
a test. Left as-is to match the file rather than making it the odd one out.


Review round 2

1. Hooks and settings in the child — fixed, and measured rather than assumed.
claude --help (2.1.246):

--setting-sources <sources>   Comma-separated list of setting sources to load
                              (user, project, local).

Measured it rather than trusting the description. A project .claude/settings.json with a
UserPromptSubmit hook, child run with that directory as cwd:

--- WITHOUT --setting-sources ---
HOOK FIRED
--- WITH --setting-sources "" ---
exit=0
hook did not fire
stdout: OK

So the finding was real, and --setting-sources "" closes it without breaking auth. Three changes
went in together:

  • --setting-sources "" in the argv, asserted in a test.
  • The child runs in a directory made by mkdtemp per call and removed afterwards, so there is no
    project CLAUDE.md, settings or memory above it to discover. A test asserts the cwd is under
    the system temp dir, is empty, is not process.cwd(), is shared by the status probe and the
    fetch, and is gone afterwards.
  • The environment is now an allowlist (HOME, PATH, shell/locale basics, ANTHROPIC_*, proxy
    and CA variables) instead of a copy of this process's with three keys deleted. The three Claude
    Code nesting markers fall out of it for free.

2. MCP endpoint validation — fixed. resolveGleanServer now requires Scope: to be the
user's own config, because a local or project-scoped entry can shadow the real server and a
checked-out repository is somewhere one could come from, and requires the URL: to parse as
https:. The hostname is deliberately not checked against a list: every company has its own
Glean host and this is a public repo. Three tests: plain http rejected, non-user scope rejected,
https user-scope accepted (plus a non-URL rejected).

3. Handle normalisation — documented limit. The check already compared whole normalised
strings rather than prefixes; that is now pinned by a test (Test Userson and test are both
rejected against Test User/testuser). docs/setup.md names the remaining limit: it compares
names, not Slack member ids, so a colleague whose name normalises to the same string would pass,
and Glean does not reliably expose member ids to compare instead.

Live re-verification

Availability ok, one week in 197s, three messages kept, 0 not public, 0 not yours, 0 malformed, 0 outside the range, zero warnings. Payload keys, channelType: "public", and https *.slack.com
permalinks all as expected. The tightened environment and settings isolation do not break OAuth.

One more fix, found the hard way

While mutation-testing the new cwd handling, a mutation pointed the directory factory at
process.cwd(). The cleanup is an rm -rf, so the test run deleted this worktree. Nothing was
lost (everything was committed) but the lesson stands: that cleanup was safe only because the
factory happened to always be mkdtemp. It now refuses any path that is not directly under the
system temp directory with this module's prefix, and the factory is injectable so the refusal is
tested against throwaway directories.

Checks

bunx tsc --noEmit clean; bun run lint clean; bun run test 688 passing across 22 files.
Eighteen guards now mutation-checked in total; the five new ones (temp directory removed, the
deletion guard itself, the scope check, the https check, whole-name matching) each turn tests red
when removed.

anti-slop-check still reports one finding, the same vi.mock in
commands/__tests__/worklog.test.ts reported in round 1, kept to match that file's six
pre-existing mocks.


Review round 3

1. Cleanup can delete a directory it did not create — fixed by removing the seam.
The injectable directory factory is gone from production code, so there is no longer any way for
a caller to hand this an arbitrary path. withEmptyCwd resolves the temp root with realpath
before creating anything, uses exactly what mkdtemp returned as the child cwd, records that
directory's dev/ino with lstat at creation, and checks them again before removing it.
A changed identity, or a path that is no longer a directory, means: warn, leave it, carry on.
Tests redirect TMPDIR at a sandbox instead of injecting, and cover a same-prefix decoy
(a sibling worklog-slack-* directory that must survive) and a symlink retarget (the directory
is swapped for a symlink pointing at a directory that must survive).

2. Managed policy hooks — documentation corrected. No auth redesign. Every absolute claim
about hooks is gone from README.md, docs/setup.md and the source comment. They now say that
user, project and local settings and hooks are not loaded, that organisation-managed policy
settings still apply including any hooks they define, that those hooks would see the Glean tool
responses, and that disabling them needs --bare, which never reads an OAuth login and would log
out every subscription user, so worklog does not use it. --no-slack is the answer for anyone
that affects.

3. The optional source can no longer crash the run. TMPDIR=/nonexistent used to throw
mkdtemp's error straight out through commands/worklog.ts. Setup failure is now
{ ok: false, reason } from isAvailable and an empty batch with a warning from a fetch; a
cleanup failure warns, leaves the directory and keeps the result; a directory that is already
gone says nothing. Tests for each, including one asserting nothing is spawned when setup fails.

4. Child environment — CLAUDE_CONFIG_DIR, CLAUDE_CODE_OAUTH_TOKEN and XDG_CONFIG_HOME
added.
All three appear in the installed CLI binary. CLAUDE_CONFIG_DIR is clearly load-bearing
(the bundle carries messages about its behaviour and suggests CLAUDE_CONFIG_DIR=/tmp for
ephemeral local writes). The XDG_CONFIG_HOME hits are around git and editor keybinding paths
rather than Claude's own config, so its relevance is less certain; it is preserved anyway because
passing it through costs nothing and dropping it would break anyone who relies on it. The three
nesting markers stay out. childEnvironment() is extracted and directly tested: kept keys,
dropped nesting markers, dropped unrelated secrets (GITHUB_TOKEN, ATLASSIAN_API_TOKEN,
AWS_SECRET_ACCESS_KEY), and no undefined values passed through.

Live re-verification

Availability ok; one week in 184s; 3 messages kept; 0 not public, 0 not yours, 0 malformed, 0 outside the range; zero warnings; channelType: "public", https *.slack.com permalinks.
The realpath change, the identity checks and the widened environment allowlist all hold up
against the real CLI.

Checks

bunx tsc --noEmit clean; bun run lint clean; bun run test 696 passing across 22 files.
anti-slop-check reports one finding, the same vi.mock collision reported in rounds 1 and 2.

Seven of the eight new guards are mutation-checked. The eighth, the isDirectory() test on the
cleanup path, provably changes no test: a symlink has its own inode and already fails the
dev/ino comparison. It is kept anyway, with a comment saying so, because it guards an rm -rf
and the intent should be readable without deriving it.


Review round 4

Cleanup is rmdir, never a recursive remove. The dev/ino check did not do what it looked
like it did: stat-then-delete is two path operations, and a same-user process can swap the
directory between them, at which point the recursive delete follows the swap and takes the
replacement with it. No amount of checking makes those two calls atomic.

The directory only ever holds a working directory for a child that writes nothing, so it never
needed a recursive delete. rmdir refuses a directory with anything in it and never descends,
which closes the race by construction rather than by inspection: the worst a swap can achieve is
the removal of an empty directory someone else put there. The dev/ino machinery and the
isDirectory() guard are gone with it, since both existed only to make a recursive delete safe.

Behaviour change worth knowing: anything the child leaves behind now survives and is reported as
a warning instead of being deleted.

Tests: an empty directory is removed; a non-empty one is left with a warning and its contents
intact; a same-prefix sibling and a directory reachable only through a symlink inside the cwd are
both untouched; the body's result survives a failed cleanup; an already-removed directory is
silent.

Checks

bunx tsc --noEmit clean; bun run lint clean; bun run test 704 passing across 22 files.
anti-slop-check reports the same single vi.mock collision as rounds 1 to 3.

Six mutations, all red, including the one this round is about: restoring the recursive delete
breaks the non-empty-directory test and the touches-nothing-outside test.

No live probe this round — the change is cleanup only and cannot affect what the child returns.

Slack has no API token in this setup and is not reachable directly. The only
route is Glean, and the only client that can reach Glean is the Claude Code CLI
with its Glean MCP server connected. So the source spawns `claude` with only the
Glean tools allowed, asks one focused question, and parses the answer against a
zod schema.

That makes it slow and non-deterministic, so it is treated as optional:
`isAvailable()` gates on `claude` being on PATH and `claude mcp get
glean_default` reporting Connected, and every failure path returns an empty
batch with a warning instead of throwing. A reply that does not parse gets one
stricter retry before the week gives up on Slack.

Messages are filed by their own timestamp and keyed by permalink, so adding this
source to an old week amends it rather than rewriting it. Out-of-range,
duplicate and over-cap messages are trimmed on our side rather than trusted from
the model.

The process runner is injected so tests never spawn anything. The 240s timeout
is measured: a real week of Glean search plus synthesis took 157s, and the 120s
first guess cut real answers off.
Each week's work log gains a `## Slack` section listing the user's own
public-channel messages grouped by channel, with threads kept together, and a
permalink and UTC timestamp per message. The section and its summary row only
appear when there are messages, so a run without Slack produces exactly the
bytes it produced before.

The weekly command checks availability once per run rather than once per week.
When Slack is unavailable it prints one `Slack source skipped: <reason>` line
and carries on. Slack is fetched separately from the API sources so a Slack
failure never costs the week its Jira, Confluence or GitHub data.

The brag prompt now says what Slack material is for: coaching context such as
decisions made in the open and people unblocked, not achievement evidence unless
Jira, GitHub or Confluence corroborates the same work.
Review found that `--allowedTools` pre-approves tools, it does not remove them.
Under `--permission-mode dontAsk` the child still had Bash, Read, WebFetch and
every MCP server the user has configured, so text in a Slack message could have
told it to read local files or send mail.

Verified against Claude Code 2.1.246: `--tools ""` leaves the child with no
built-in tools at all, and `--strict-mcp-config --mcp-config <glean only>`
leaves it with the Glean tools alone. The Glean server is discovered from
`claude mcp get`, so nothing company-specific is hardcoded; when it cannot be
isolated the source reports itself unavailable rather than falling back to a
wider child.

Glean is permission-aware, but the reply is still model output, so authorship
and visibility are now checked locally too. Messages carry a required `author`
and `channelType`, and anything not written by the configured profile or not in
a public channel is dropped with a count in a warning. Permalinks must be https
on slack.com, and each entry is parsed on its own so one bad entry cannot lose
the rest. The docs say plainly that this is a second check on top of Glean, not
a guarantee.

Other fixes from the same review:

- `Status:` is parsed as a word list rather than tested with `includes`, so
  "Not Connected" no longer passes the availability gate.
- Structured output (`--json-schema` + `--output-format json`) replaces brace
  extraction on the happy path. The fallback scans for a balanced JSON object
  with a depth counter instead of taking the outermost braces, so prose like
  `Found {one result}: {"messages":[]}` parses.
- One 240s budget now covers the attempt and its retry, the retry gets only the
  time left and is skipped below 60s, and `--verbose` prints the Slack seconds.
- `--no-slack` skips the source, and the availability probe, for a run.
- Slack is timed separately from the API fetch in the stats and the summary.
- Markdown link destinations are escaped.
- The byte-equality test freezes the clock, which the `Generated:` header needs.
- The brag prompt says quoted message text is data, never instruction.
- The README says what reaches Claude Code and Glean when Slack is on.
Verified live: the Claude Code CLI validates --json-schema itself and refuses
the draft/2020-12 $schema reference zod emits, so every fetch failed within a
second. Stripping it makes the structured-output path work end to end.
Text full of unclosed braces yielded nothing, so the match counter never
reached its ceiling and the scan re-ran to the end of the reply from every
brace. Counting attempts is what actually bounds the work.
runWorklog's new noSlack option is optional so a caller that predates it still
compiles, which means the default path checks Slack availability and would
spawn the Claude Code CLI from a test. The source is stood in for as
unavailable, matching how that file already stands in for the AI and network.
…ack child

Removing tools and MCP servers did not stop settings files loading, so a user
or project hook still ran inside the child and a PostToolUse hook would have
received every Glean tool response. Measured it: a project UserPromptSubmit
hook fires in the child, and with `--setting-sources ""` it does not, while the
child still authenticates and answers.

So the child now gets `--setting-sources ""`, a freshly made empty temporary
directory as its working directory (nothing above it to discover as project
CLAUDE.md, settings or memory), and an environment built from an allowlist
rather than a copy of this process's. The allowlist is what the child needs to
start, authenticate and reach the network through a proxy; the three Claude
Code nesting markers fall out of it for free.

The Glean endpoint is also checked before it is handed to the child. `Scope:`
must be the user's own config, because a local or project entry could shadow
the real server and a checked-out repository is a place one could come from,
and the `URL:` must parse as https. The hostname is deliberately not checked
against a list: every company has its own Glean host and this is a public repo.

Verified live after the change: availability ok, one week in 197s, three
messages kept, nothing rejected, no warnings.

The author check already required the whole normalised name to match rather
than a prefix; that is now pinned by a test. `docs/setup.md` names the limit it
still has, that two people whose names normalise alike would both pass, and why
member ids are not used instead.
…eate

The cleanup after each child process is an rm -rf on whatever path the
directory factory returned. That was safe only because the factory was always
mkdtemp, and it is now provably unsafe to rely on: a mutation test that pointed
it at process.cwd() deleted this worktree outright.

The path is now checked before it is removed: it has to sit directly under the
system temp directory and carry this module's prefix. The factory is injectable
so the refusal is tested against throwaway directories rather than by stubbing
node:fs, which vitest cannot spy on in ESM anyway.
…ailures survivable

Four things, all about the same idea: an optional source may decline to run, but
it may not take the run down with it, and its cleanup may not touch anything it
did not create.

The directory factory is gone from production code. There is no seam a caller
can hand a path to any more: the temp root is resolved with `realpath` before
creation, the child's cwd is exactly what `mkdtemp` returned, and cleanup
records the created directory's dev and ino with `lstat` and refuses to remove
anything whose identity has changed or which is no longer a directory. Tests
redirect `TMPDIR` instead of injecting, and cover a same-prefix decoy and a
symlink retarget.

Neither failure is fatal now. `TMPDIR` pointing nowhere used to throw straight
out through the weekly command; it becomes `{ ok: false }` from `isAvailable`
and an empty batch with a warning from a fetch. A cleanup that cannot run warns,
leaves the directory, and keeps the result; a directory that is already gone
says nothing.

The child environment keeps `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_OAUTH_TOKEN` and
`XDG_CONFIG_HOME`. All three appear in the installed CLI, and dropping the first
two would log out anyone who has moved their config or authenticates by token.
The three nesting markers stay out.

The docs no longer claim no hooks run. User, project and local settings are not
loaded, but organisation policy settings are, including any hooks they define,
and those would see the Glean tool responses. Turning them off needs `--bare`,
which never reads an OAuth login, so worklog does not use it and says so.
Mutation testing showed removing it changes no test: a symlink has its own
inode and already fails the dev/ino comparison. Kept anyway, because this
guards an rm -rf and a reader should not have to derive that.
… remove

The dev/ino check did not do what it looked like it did. Stat-then-delete is two
path operations, and another process running as this user can swap the directory
between them; the recursive delete would then follow the swap and take the
replacement with it. No amount of checking makes those two calls atomic.

The directory only ever holds a working directory for a child that writes
nothing, so it does not need a recursive delete at all. `rmdir` refuses a
directory with anything in it and never descends, which closes the race by
construction rather than by inspection: the worst a swap can achieve now is the
removal of an empty directory someone else put there.

That takes the dev/ino machinery and the isDirectory guard with it, both of
which existed only to make a recursive delete safe. Anything the child does
leave behind survives and is reported as a warning instead of being deleted.
@bhekanik
Bhekani Khumalo (bhekanik) force-pushed the feat/m3-slack-source branch 2 times, most recently from 34ba82a to 0f4870f Compare August 27, 2026 15:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant