Citadel watch

install
source · Clone the upstream repo
git clone https://github.com/SethGammon/Citadel
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/SethGammon/Citadel "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/watch" ~/.claude/skills/sethgammon-citadel-watch && rm -rf "$T"
manifest: skills/watch/SKILL.md
source content

/watch -- File Sentinel

Default execution path (READ FIRST)

/watch start
does NOT call
CronCreate
by default.
The local runner is the default. Only pass
--remote
to use Anthropic's routine system, and only after explicit user confirmation.

Why:

CronCreate
counts against the account-wide 15 routine runs / 24h cap. At the default 5-minute interval a watch exhausts the quota in under an hour and pauses every other routine on the account. See docs/ROUTINE-QUOTA.md.

Default flow —
/watch start
(no
--remote
flag)

  1. Do Steps 1 and 2 below (check existing watch, determine baseline commit).
  2. Skip Step 3 — do NOT call
    CronCreate
    . Leave
    cronId: null
    in the state file.
  3. Write the state file (Step 4) with
    status: "watching"
    .
  4. Output (instead of Step 5):
    Watch state created: .planning/watch-state.json
      Baseline: {commit hash, first 7 chars}
    
    To start real-time watching, run in a separate terminal:
      npm run watch:local
    
    It uses filesystem events (not polling), triggers scans on change, and
    consumes zero Anthropic routine quota. Stop with Ctrl+C.
    
    For cloud-persistent polling (machine off, user away):
      /watch start --remote     (uses CronCreate, counts against 15/day cap)
    

Opt-in routine flow —
/watch start --remote

Only when

--remote
is explicitly passed:

  1. Confirm: "This will use
    CronCreate
    , which counts against your 15 routine runs / 24h quota. At a 5-minute interval this exhausts the quota in under an hour. Continue? (y/N)"
  2. On confirmation, run the full Step 1–5 protocol below including Step 3's
    CronCreate
    call.

The rest of the protocol documents the full routine-path flow for reference and for

--remote
invocations.

Identity

You are the file sentinel. You detect what changed since the last scan, find marker comments that request specific actions, and dispatch work to the right skill or queue it for batch processing. You do not do the work yourself -- you detect and route.

Orientation

Use

/watch
when:

  • The user wants automatic reactions to file changes (tests on test edits, doc staleness checks on doc changes, review on marker comments)
  • A daemon or autopilot pipeline needs a change-detection feed
  • The team uses
    @citadel:
    marker comments to request actions inline

Do NOT use

/watch
for:

  • One-off file inspection (just read the file)
  • Continuous real-time filesystem monitoring (Claude Code sessions are ephemeral)
  • Tasks that need human judgment per file (use
    /review
    directly)

Commands

CommandBehavior
/watch start
Default: create state, prompt user to run
npm run watch:local
(real-time, zero routine cost)
/watch start --remote
Use
CronCreate
polling instead (counts against 15/day routine quota — requires confirmation)
/watch start --interval {N}m
Set poll interval for
--remote
mode (default: 5m)
/watch stop
Stop watching, tear down cron
/watch status
Show watch state, last scan time, pending actions
/watch scan
Run a single scan now (manual trigger)

Protocol

/watch start

Step 1: Check for existing watch

  1. Read
    .planning/watch-state.json
    if it exists
  2. If
    status
    is
    "watching"
    :
    • Show current state: last scan time, interval, pending actions count
    • Ask: "A watch is already active. Stop it and start a new one?"
    • If yes: run
      /watch stop
      first, then continue
    • If no: abort

Step 2: Determine baseline commit

  1. Run
    git rev-parse HEAD
    to get the current commit hash
  2. If not a git repo: fall back to timestamp-based detection (store current time as
    lastScanTime
    , skip commit-based diffing)
  3. Store this as
    lastScanCommit
    -- the first scan will diff against this

Step 3: Create poll schedule

Use CronCreate to set up recurring scans:

CronCreate:
  interval: "{N}m"  (default: 5m)
  command: "/watch scan"

Save the cron ID in the state file.

Step 4: Write state file

Write

.planning/watch-state.json
:

{
  "status": "watching",
  "lastScanCommit": "abc1234",
  "lastScanTime": null,
  "interval": "5m",
  "cronId": "{id from step 3}",
  "pendingActions": [],
  "processedMarkers": [],
  "stats": {
    "scansRun": 0,
    "markersFound": 0,
    "intakeItemsCreated": 0,
    "skillsDispatched": 0
  }
}

Step 5: Confirm

Output:

Watch started.
  Interval:  every {N}m
  Baseline:  {commit hash, first 7 chars}
  State:     .planning/watch-state.json

The sentinel will scan for changes every {N} minutes and:
  - Route @citadel: marker comments to the appropriate skill
  - Queue unmarked changes as intake items for /autopilot
  - Auto-trigger test runs when test files change
  - Flag doc staleness when source files change near docs

Use `/watch scan` for an immediate scan.
Use `/watch stop` to halt.

/watch stop

  1. Read
    .planning/watch-state.json
    . If it doesn't exist or
    status
    is not
    "watching"
    : "No watch is active."
  2. Delete the cron schedule:
    CronDelete: {cronId}
    
    If the cron ID is missing or deletion fails, continue gracefully.
  3. Update state file:
    {
      "status": "stopped",
      "cronId": null
    }
    
    Preserve all other fields (stats, lastScanCommit, etc.).
  4. Output:
    Watch stopped.
      Scans completed:     {stats.scansRun}
      Markers found:       {stats.markersFound}
      Intake items created: {stats.intakeItemsCreated}
      Skills dispatched:   {stats.skillsDispatched}
    

/watch status

  1. Read
    .planning/watch-state.json
    . If it doesn't exist: "No watch configured. Use
    /watch start
    to begin."
  2. Output:
    Watch: {status}
      Last scan:      {lastScanTime or "never"}
      Last commit:    {lastScanCommit, first 7 chars}
      Interval:       {interval}
      Pending actions: {pendingActions.length}
      Stats:
        Scans run:         {stats.scansRun}
        Markers found:     {stats.markersFound}
        Intake items:      {stats.intakeItemsCreated}
        Skills dispatched: {stats.skillsDispatched}
    
  3. If
    pendingActions
    is non-empty, list each:
    Pending:
      [{action}] {file}:{line} -- {description}
    

/watch scan

This is the core detection and dispatch loop. Runs on every poll tick or when invoked manually.

Step 1: Load state

  1. Read
    .planning/watch-state.json
  2. If it doesn't exist: create a default state with
    lastScanCommit
    from
    git rev-parse HEAD
    and
    status: "watching"
    . This allows
    /watch scan
    to work as a standalone one-shot without
    /watch start
    .

Step 2: Detect changed files

Git mode (primary):

  1. Run
    git diff --name-only {lastScanCommit} HEAD
    to get files changed since the last scan
  2. Also run
    git diff --name-only
    (unstaged) and
    git diff --name-only --cached
    (staged) to catch uncommitted work
  3. Merge and deduplicate all three lists
  4. Filter out files matching
    .gitignore
    (git diff handles this automatically for committed changes; for unstaged, use
    git ls-files --others --ignored --exclude-standard
    to identify ignored files and exclude them)

Fallback mode (no git):

  1. Walk the working directory using
    find . -newer {timestamp_file} -type f
  2. Exclude
    node_modules/
    ,
    .git/
    ,
    .planning/
    ,
    dist/
    ,
    build/
  3. This is less precise but functional for non-git projects

If no files changed: update

lastScanTime
and
stats.scansRun
, exit early.

Step 3: Scan for marker comments

For each changed file, read its contents and search for marker patterns:

PatternLanguages
// @citadel: {action} {description}
JS, TS, Go, Rust, C, Java
# @citadel: {action} {description}
Python, Shell, YAML, Ruby
/* @citadel: {action} {description} */
CSS, multi-line C-style
<!-- @citadel: {action} {description} -->
HTML, Markdown

Extract from each match:

  • action
    : the first word after
    @citadel:
    (e.g.,
    review
    ,
    test
    ,
    fix
    )
  • description
    : everything after the action word
  • file
    : the file path
  • line
    : the line number where the marker was found

Action-to-skill mapping:

ActionSkillDescription
review
/review
Request a code review of this file or section
test
/test-gen
Generate tests for this code
fix
/systematic-debugging
Investigate and fix a bug described in the marker
document
/doc-gen
Generate or update documentation
refactor
/refactor
Refactor the marked code
todo
intake itemAdd to intake queue for batch processing

Unknown actions are treated as intake items with the action preserved as metadata.

Deduplication: Compare each marker against

processedMarkers
in the state file (stored as
"{file}:{line}:{action}"
strings). Skip markers that have already been processed. This prevents re-dispatching the same marker on every scan. A marker is removed from
processedMarkers
when the file is modified again (the line content changed).

Step 4: Classify unmarked changes

For changed files without markers, classify by file type and location:

File patternAuto-action
*.test.*
,
*.spec.*
,
__tests__/*
Queue: "run tests" intake item
*.md
in
docs/
or project root
Queue: "doc staleness check" intake item
src/**/*.ts
,
src/**/*.tsx
Queue: "changed source" intake item (informational)
package.json
,
tsconfig.json
Queue: "config change" intake item (high priority)

Step 5: Dispatch markers

For each new (non-duplicate) marker:

  1. Route through
    /do
    with context:
    /do {action} in {file} at line {line}: {description}
    
  2. Log the dispatch to the state file
  3. Add to
    processedMarkers
  4. Increment
    stats.skillsDispatched

Batch limit: Dispatch at most 5 marker actions per scan. If more exist, queue the remainder in

pendingActions
for the next scan. This prevents a single scan from consuming the entire session context.

Step 6: Write intake items

For each classified change (unmarked files + overflow markers), write an intake item to

.planning/intake/
:

Filename:

watch-{timestamp}-{index}.md

---
source: watch
priority: {normal|high}
created: {ISO timestamp}
---

# {brief description}

File: {file path}
Change type: {new|modified|deleted}
Classification: {test change|doc change|source change|config change|marker overflow}
{If marker: Action: {action}, Description: {description}}

Detected by /watch scan at {ISO timestamp}.

Deduplication: Before writing, check if an intake item already exists for this file with the same classification (glob

.planning/intake/watch-*
and grep for the file path). Skip if a duplicate exists.

Increment

stats.intakeItemsCreated
for each new item written.

Step 7: Update state

Update

.planning/watch-state.json
:

  • lastScanCommit
    :
    git rev-parse HEAD
    (current HEAD)
  • lastScanTime
    : current ISO timestamp
  • stats.scansRun
    : increment
  • stats.markersFound
    : add count of new markers found this scan
  • pendingActions
    : any overflow actions not dispatched this scan
  • processedMarkers
    : append newly processed markers

Step 8: Report

If running interactively (manual

/watch scan
), output:

Scan complete.
  Files changed:    {N}
  Markers found:    {new markers} ({total processed} total)
  Actions dispatched: {N} (batch limit: 5)
  Intake items:     {N} written to .planning/intake/
  Pending actions:  {N} (will dispatch on next scan)

If running from a cron poll, output nothing (silent operation).


Integration Points

  • Intake pipeline: Writes items to
    .planning/intake/
    for consumption by
    /autopilot
    . Items include file context and classification metadata.
  • Intent router: Routes marker actions through
    /do
    , which handles skill dispatch. Watch never invokes skills directly.
  • Daemon:
    /daemon
    can start a watch alongside a campaign. The watch feeds intake items that the daemon's campaign can consume.
  • Session-start hook: The
    init-project.js
    hook can trigger a scan on session start if
    .planning/watch-state.json
    has
    status: "watching"
    . This catches changes made between sessions.

Fringe Cases

.planning/
does not exist: Create
.planning/
and
.planning/intake/
on first scan. Do not require
/do setup
-- watch should be lightweight enough to bootstrap its own state directory.

Not a git repo: Fall back to timestamp-based change detection. Warn on first scan: "Not a git repo. Using file modification times for change detection. This is less precise and does not respect .gitignore automatically."

No files changed since last scan: Update stats and exit silently. This is the normal case for most polls.

Marker comment has an unknown action: Treat as an intake item with the raw action preserved in metadata. Do not error -- unknown actions may be handled by custom skills the user has installed.

File was deleted between scans: Skip marker scanning for deleted files. Write an intake item noting the deletion if the file was previously tracked.

Very large diff (100+ files): Cap marker scanning at the first 50 changed files per scan. Queue the rest for the next scan. Log: "Large changeset detected ({N} files). Scanning first 50 this cycle."

Binary files in the diff: Skip binary files during marker scanning. Detect via

git diff --numstat
(binary files show
-
for additions/deletions).

watch-state.json is corrupted or missing required fields: Reset to defaults. Preserve

processedMarkers
if readable to avoid re-dispatching old markers. Log: "Watch state was corrupted. Reset to defaults."

CronCreate not available: Warn: "CronCreate is not available.

/watch start
requires session-scoped scheduling. Use
/watch scan
for manual one-shot scans instead."

Multiple scans overlap (slow scan + fast interval): The cron interval should be longer than scan duration. If a scan takes longer than expected, log a warning: "Scan took {N}s (interval is {M}m). Consider increasing the interval." The state file's

lastScanTime
acts as a soft lock -- if
lastScanTime
is within the last 60 seconds, skip the scan.

Marker removed from file: On each scan, check if previously processed markers still exist at their recorded file:line location. If the marker was removed (user addressed the action), remove it from

processedMarkers
. This keeps the processed list from growing unbounded.


Quality Gates

  • Scan must complete in under 10 seconds for repos up to 100K lines
  • Must not create duplicate intake items for the same file and classification
  • Must not re-dispatch markers that have already been processed
  • Must respect
    .gitignore
    (automatic in git mode, manual exclusion list in fallback mode)
  • Batch limit of 5 dispatches per scan must be enforced -- never consume the full session context on a single scan
  • State file must be updated atomically at the end of each scan, not incrementally during the scan (prevents partial state on crash)
  • Must work on Windows, macOS, and Linux (Node.js fs + git CLI, no platform-specific filesystem watchers)
  • CronCreate failure must not leave watch in an inconsistent state

Exit Protocol

After
/watch start
:

Output the confirmation block. No HANDOFF -- the watch runs in the background.

After
/watch stop
:

Output the stop summary with lifetime stats.

After
/watch scan
(manual):

Output the scan report with counts.

After
/watch scan
(cron):

Silent. Updates state file only.

After
/watch status
:

Output current state. Wait for next command.

On error:

Output a clear message with fix. Never leave cron running if state is inconsistent -- clean up on error.

---HANDOFF---
- Built: skills/watch/SKILL.md -- file sentinel skill with poll-based change detection
- Commands: start, stop, status, scan with git diff against stored commit hash
- Detection: marker comments (@citadel: action) in 4 comment styles + file classification
- Dispatch: markers route through /do (batch limit 5), unmarked changes become intake items
- Integration: feeds .planning/intake/ for /autopilot, bridges with /daemon and session-start hook
---