-
Fix: the Obsidian adapter's Report Hub settings tab and output-folder cleanup had four related
bugs, all found during an/impeccablecritique ofapps/obsidian-plugin(public repo issues
#21, #22, #25, #26):- Routine output-folder cleanup (every Compile/Weekly Review/LLM Handoff/Validate command) only
ever considered the two built-in folder names as sweep candidates, regardless of whether this
plugin instance had ever actually used them. A file reappearing in an unused built-in folder
(e.g. from a vault sync or backup restore) was silently deleted on the next compile. Cleanup
now sweeps only folders in a persistedmanagedOutputFoldersset, populated as the user
actually consents to output-folder changes. - Output-folder cleanup could delete empty ancestor directories above the managed output folder
(walking up toward the vault root) even when the plugin did not create them. It now only ever
removes the exact managed folder itself. - The custom-path text field's blur-commit and the visibility dropdown's change handler could
race on a fast user gesture, opening two confirm modals for one logical change and reading
settings before either had settled. Output-folder mutations are now serialized. - Selecting "Custom path" in the settings dropdown no longer eagerly persists
outputFolderVisibility: "custom"before an actual custom folder is committed; declining the
subsequent confirm modal now cleanly reverts instead of leaving a mismatched
custom+ built-in-folder state. The path field is now focused only on the transition into
custom mode (not on every settings-tab re-render), and the validation banner refreshes
immediately after a successful commit instead of waiting for the tab to be reopened. - (Codex review follow-up) A
data.jsonpredatingmanagedOutputFoldersgrandfathered the
hidden folder into that set regardless of which folder the vault actually used, reopening the
same silent-deletion risk the field was added to close. Legacy settings now start from an empty
managed set, plus only the folder actually in use. Serializing racing blur/dropdown calls also
still let a fast gesture open two sequential confirm modals (the blur commit's, then the
dropdown's, against whatever the first one left behind) instead of one; a generation counter now
cancels a superseded queued call outright so only the most current intent ever prompts.
- Routine output-folder cleanup (every Compile/Weekly Review/LLM Handoff/Validate command) only
-
Security:
writeText/mkdirin everynode:fs-backedFileSystemAdapter(packages/cli,
packages/mcp, and the corresponding test helpers) now delete a pre-existing symlink at the
target path before creating a real file/directory there, instead of writing through it.
fs.writeFilefollows a symlink at its final path segment; a vault (or CLI output directory)
that already had a symlink planted at a generated artifact's predictable name (e.g.
.gotsaeng/context-pack/PROJECT_CONTEXT.md) would have that symlink's target truncated and
overwritten by a compile, corrupting whatever file or directory it pointed to outside the vault.
writeTextgates the removal onlstat(not a plain existence check, and not an unconditional
remove):lstatreports the link itself rather than following it, so it also catches a
dangling symlink (target missing) that anexists()-style check would miss and let the write
follow anyway — and only removing when the target is actually a symlink preserves the
mode/permissions of a normal pre-existing file across an overwrite, instead of losing them to a
remove-then-recreate.apps/obsidian-plugin/src/obsidian-file-system.ts'swriteTextgot the
equivalent unconditional try/catch-wrapped removal (Obsidian'sDataAdapterexposes nolstat
equivalent to gate on directly, somkdirthere is unchanged — see its inline comment), but only
swallows a confirmedENOENTfrom that removal — any other failure (e.g. a symlink sitting in a
directory this process can't write to) now aborts the write instead of falling through to
adapter.write(), which would still follow whatever the failed removal left in place. None of
this protects against a symlinked ancestor directory in the output path, only the leaf artifact
path itself; closing that fully would need validating every path segment, a separate, larger
pass. -
Security:
.github/workflows/release.yml'squalityjob now retains theobsidian-plugin-dist
artifact for 14 days instead of the 1-day default. Thepublishjob can sit waiting on a
required-reviewer approval (seedocs/release.md's "Optional: require manual approval before
publish") for as long as the approver takes; a 1-day retention could expire the artifact mid-wait,
sogithub-releasewould fail to download it afterpublishalready published the (now
immutable) npm packages, leaving the release stuck with no clean way to finish just the GitHub
Release step. -
Security:
.github/workflows/release.yml'sgithub-releasejob no longer runspnpm install/pnpm builditself while holding acontents: writetoken — a compromised dependency's
install/build script running in that job could have used the checkout-persisted git credential to
push commits or tags. It now downloads the Obsidian plugin build produced under thequality
job's read-only token instead. All three jobs'actions/checkoutsteps now set
persist-credentials: false. -
Security:
.github/workflows/release.yml'squalityjob now refuses to proceed (before
installing any dependency or running any script) unless the pushed tag's commit is reachable from
origin/main— a tag alone only proves tag-push rights, not that its target was reviewed;
without this check, and absent separate tag protection in repo settings, anyone who could push a
tag could point a version release at unreviewed history.docs/release.mdalso documents an
optional additional layer: binding thepublishjob to a GitHub Environment (release) that can
be configured with required-reviewer approval. -
Security:
.claude/skills/address-pr-review/SKILL.mdnow checks out a PR withgh pr checkout <n>instead ofgit checkout <headRefName>. A fork PR'sheadRefNameis attacker-controlled,
not globally unique (can collide with an existing local/origin branch, includingmain), and can
contain shell metacharacters —gh pr checkoutresolves the PR by its immutable number via the
API instead of building a git command out of that text. Also added an explicit trust caveat,
since the skill goes on to run this repo's own package scripts and push a commit. -
Obsidian adapter:
GotSaengSettingTabnow implements the declarative settings API
(getSettingDefinitions/getControlValue/setControlValue, Obsidian >=1.13.0), so its six
settings are searchable in Obsidian's built-in settings search.display()stays as the
fallback for hosts older than 1.13.0 —manifest.json'sminAppVersionis unchanged (1.5.0).
Four settings (project name, stale days, strict validation, open-after-compile) use native
controldefinitions. The other two (output folder visibility, output folder path) use the
renderescape hatch instead: nocontroltype exposes a blur-only commit or a
confirm-before-persist gate, and reproducing either with a nativetext/dropdowncontrol's
per-changesetControlValuewould fire the 0.12.0 delete-confirmation dialog once per
keystroke — exactly the regression that release fixed. Bothrenderdefinitions share their
actual logic withdisplay()via two extracted methods, so nothing is implemented twice.
Closes #24. -
Obsidian adapter:
apps/obsidian-plugin/tsconfig.jsonnow includes theDOMlib. Without it
HTMLElementand friends resolved to TypeScript'serrortype, so everycreateEl,
createDiv, andaddEventListenercall inmain.tsandview.tswas silently unchecked —
tscstayed quiet because theerrortype is assignable to everything. This is what produced
the bulk of the "unsafe member access on ananyvalue" findings in the Obsidian community
plugin scorecard for the adapter's own source. Types only; no runtime change. -
Tooling: ESLint now runs
typescript-eslint'srecommendedTypeCheckedrule set over all
TypeScript, wired to the real tsconfigs viaprojectService. Theno-unsafe-*rules that
the scorecard reports were never active locally, sopnpm lintcould not catch what it
flagged.pnpm lintstill exits 0 at--max-warnings 0. -
Core: validation messages for
type,created, andupdatednow show the offending
frontmatter value as JSON when it is a map or a sequence. They previously ran it through
String(), which rendered any object-valued field as an unhelpful[object Object]. -
CLI: the
--jsonpayload shapes are now named types (CompileJsonPayload,CliErrorJsonPayload,
ValidationJsonPayload) annotated onto the literalspackages/cli/src/output.tsemits, so a
drift from the documented schema is a type error. This is an internal type-safety change, not a
new public API:output.tsis not re-exported from the package entry point. Output is
byte-identical. -
Core:
packages/coreno longer importsnode:fs(orfast-glob, which pulls it in) anywhere.
Every read/write goes through a newFileSystemAdapterinterface
(adapters/file-system.ts) injected by the caller:compileContextPack,writeContextPack,
scanSourceFiles/scanMarkdownFiles,parseMarkdownFile, and every exporter now take one as
their first argument.packages/cliandpackages/mcpeach construct anode:fs-backed
implementation (node-file-system.ts);apps/obsidian-pluginconstructs one backed by
app.vault.adapterinstead (obsidian-file-system.ts), translating the same absolute,
under-vault-root paths core has always used into the vault-relative paths Obsidian's adapter
expects.output-cleanup.tsand the three remaining direct-fscall sites inmain.ts(output
read/write, compile-report read) were converted the same way. Scanning itself moved from
fast-glob to anadapter.list()-based recursive walk withmicromatchignore-glob filtering, a
known-**-suffix pruning optimization to avoid walking.git/node_modules-sized ignored
subtrees, and explicit dotfile exclusion to preserve fast-glob's olddot: falsebehavior.
Closes #22.apps/obsidian-plugin/dist/main.jsalso aliasesfs/node:fsto a throwing stub at
build time (tsup.config.ts,scripts/fs-stub.cjs), sincegray-matter'sindex.jsdoes an
unconditional (but, for how this plugin calls it, unreachable) top-levelrequire('fs')for its
unusedmatter.read(filePath)overload — the stub is what makes "nonode:fsimport reachable
from the built plugin" true of the bundle as shipped, not just of this repo's own source.
Local-only I/O throughout; no behavior change for CLI/MCP users, and Obsidian users get the same
compiled output as before.