Shared input validation module for plan-marshall scripts — validates plan IDs, file paths, enums, and skill notation
Resources
2Install
npx skillscat add cuioss/plan-marshall/tools-input-validation Install via the SkillsCat registry.
Input Validation Base Skill
Role: Shared Python module providing input validation functions for plan-marshall scripts. Prevents path traversal, invalid plan IDs, and malformed inputs from reaching filesystem operations.
Enforcement
Execution mode: Library module; import validators as documented in usage examples.
Prohibited actions:
- Do not validate plan IDs with custom regex; use
validate_plan_id() - Do not validate file paths without
validate_relative_path() - Do not bypass validation in scripts that accept user/LLM input
Constraints:
- All plan IDs must match
^[a-z][a-z0-9-]*$, with the single documented exception of theNO_PLANsentinel - Relative paths must reject absolute paths and traversal sequences
- Use raising validators for fail-fast behavior, bool validators for conditional logic
What This Skill Provides
- Canonical regex constants for the full identifier vocabulary (single source of truth)
- Plan ID validation (kebab-case format, plus the
NO_PLANplan-less carve-out) - Lesson, session, task, component, hash, phase, field, module, package, domain, and resource-name validators
- Relative path validation (rejects absolute paths and traversal)
- Enum membership validation
- Skill notation validation (bundle:skill format)
- Both raising (ValueError) and bool return styles
- Argparse builder helpers (
add_<id>_arg(parser)) that wire the validator into argparsetype=so malformed input is rejected at the script boundary
When to Use
Import input_validation module in Python scripts that:
- Accept
plan_idarguments for path construction - Accept file paths from user/LLM input
- Need enum validation for argument values
- Reference skill notation strings
Module: input_validation.py
Location: scripts/input_validation.py
Functions
Raising Validators (return validated value or raise ValueError)
1. validate_plan_id(plan_id: str) -> str
- Purpose: Validate plan_id matches
^[a-z][a-z0-9-]*$, or is theNO_PLANsentinel - Input:
plan_idstring - Output: The validated plan_id string
- Raises:
ValueErrorif invalid - Note: the sentinel carve-out is tested ahead of
PLAN_ID_REand this function is the single canonical place it lives — never re-implement it in a caller. See § "TheNO_PLANsentinel (plan_id carve-out)"
2. validate_relative_path(file_path: str) -> str
- Purpose: Reject absolute paths and
..traversal - Input:
file_pathstring - Output: The validated file_path string
- Raises:
ValueErrorif empty, absolute, or contains traversal
3. validate_enum(value: str, allowed: list, label: str) -> str
- Purpose: Check value is in allowed list
- Input:
value, list ofallowedvalues,labelfor error message - Output: The validated value string
- Raises:
ValueErrorif not in allowed list
4. validate_skill_notation(skill: str) -> str
- Purpose: Validate
bundle:skillformat - Input:
skillnotation string - Output: The validated skill string
- Raises:
ValueErrorif not inbundle:skillformat
5. validate_script_notation(notation: str) -> str
- Purpose: Validate
bundle:skill:scriptformat (3-part executor notation) - Input:
notationstring - Output: The validated notation string
- Raises:
ValueErrorif not inbundle:skill:scriptformat
Bool Companions (drop-in replacements for existing patterns)
6. is_valid_plan_id(plan_id: str) -> bool
- Purpose: Check if plan_id is valid (no exception)
- Input:
plan_idstring - Output:
Trueif valid,Falseotherwise - Note: delegates to
validate_plan_id, so it inherits theNO_PLANcarve-out
7. is_valid_relative_path(file_path: str) -> bool
- Purpose: Check if file path is valid relative path (no exception)
- Input:
file_pathstring - Output:
Trueif valid,Falseotherwise
Lesson-ID Reference Scanner (Live-Anchored)
These helpers detect lesson-ID-shaped tokens embedded in arbitrary prose
(typically task titles and descriptions) and verify them against the livemanage-lessons inventory. They are the single source of truth for
"is this a real lesson ID?" checks across the bundle, used bymanage-tasks (at-write-time validation) and plan-doctor (post-hoc
plan diagnostics).
All three helpers reuse the canonical LESSON_ID_RE constant — no new
regex literal is introduced. The embedded scanner uses an unanchored
derivative (_LESSON_ID_EMBEDDED_RE) of the same pattern with non-digit
boundary lookarounds so adjacent digits don't bleed into a match.
Live-Anchor Discipline
The canonical regex shape is asserted
against actual repo data at runtime — not only in the test suite. On
first invocation per process, scan_lesson_id_tokens andverify_lesson_ids_exist call verify_lesson_id_regex_against_inventory,
which:
- Spawns
manage-lessons listand parses the TOON inventory. - If the inventory has IDs and at least one matches
LESSON_ID_RE,
caches success for the rest of the process. - If the inventory is empty (greenfield repo or wiped state), emits a
one-time WARNING tostderrand treats the anchor as a no-op so
greenfield use isn't a hard error. - If the inventory has IDs but NONE match
LESSON_ID_RE, raisesLessonRegexAnchoringErrorwith the regex pattern and a sample of the
unmatched IDs. The cache is NOT set, so every subsequent scanner call
keeps failing until the regex (or the IDs) is corrected. This is the
failure mode that exists precisely because regex-vs-inventory drift can
otherwise produce a silent "no IDs match anything" false-clean signal.
Public API
8. scan_lesson_id_tokens(text: str) -> list[str]
- Purpose: Return every lesson-ID-shaped token embedded in
text. - Input: arbitrary
text(e.g., a task title + description). - Output: list of matching tokens in order of appearance.
- Raises:
LessonRegexAnchoringError,LessonInventoryUnavailable
via the first-use anchor check.
9. verify_lesson_ids_exist(tokens: Iterable[str]) -> dict[str, bool]
- Purpose: Return
{token: present}for each token by lookup against
the livemanage-lessonsinventory. Duplicates de-duplicated. - Input: iterable of candidate lesson-ID tokens.
- Output: dict mapping each unique token to
Trueif found in the
live inventory,Falseotherwise. - Raises:
LessonInventoryUnavailablewhen the subprocess fails —
NEVER silently returns "all present". AlsoLessonRegexAnchoringError
via the first-use anchor check.
10. verify_lesson_id_regex_against_inventory() -> None
- Purpose: Runtime live-anchor check (see "Live-Anchor Discipline"
above). Idempotent within a process. - Output:
Noneon success or empty-inventory no-op. - Raises:
LessonRegexAnchoringErrorwhen live IDs exist but none
matchLESSON_ID_RE;LessonInventoryUnavailableon subprocess
failure.
Typed Exceptions
LessonInventoryUnavailable(RuntimeError) — raised whenevermanage-lessons list cannot be invoked, exits non-zero, or returns
output the TOON parser rejects. Callers MUST surface this; silently
degrading to "all present" defeats the entire purpose of the scanner.
LessonRegexAnchoringError(RuntimeError) — raised when the live
inventory contains IDs but none of them match LESSON_ID_RE. Carriesregex (the pattern that failed to anchor) and sample_ids (a slice of
the unmatched IDs) for diagnostics.
Usage
from input_validation import (
LessonInventoryUnavailable,
LessonRegexAnchoringError,
scan_lesson_id_tokens,
verify_lesson_ids_exist,
)
text = "Per lesson 2026-04-29-10-001, anchor regex against live inventory."
try:
tokens = scan_lesson_id_tokens(text)
presence = verify_lesson_ids_exist(tokens)
missing = [tok for tok, ok in presence.items() if not ok]
if missing:
raise ValueError(f"Unresolved lesson IDs: {missing}")
except LessonRegexAnchoringError as exc:
# Hard fail — the regex shape has drifted from the inventory.
raise
except LessonInventoryUnavailable as exc:
# Hard fail — inventory unreachable; do not silently pretend
# everything is present.
raiseError Responses
status: error
error: validation_failed
message: Plan ID contains invalid characters: bad!!idCanonical Identifier Registry
The Carve-out column is part of the contract, not a footnote: a value listed there is accepted by the identifier's validate_* function even though the Regex column rejects it. Exactly one identifier has one.
| Identifier | Regex | Builder | Carve-out |
|---|---|---|---|
plan_id |
^[a-z][a-z0-9-]*$ |
add_plan_id_arg |
NO_PLAN — accepted by validate_plan_id (and is_valid_plan_id by delegation), not matched by PLAN_ID_RE. See § below |
lesson_id |
^[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]+$ |
add_lesson_id_arg |
none |
session_id |
^[A-Za-z0-9_-]{1,128}$ |
add_session_id_arg |
none |
task_number |
^[0-9]+$ |
add_task_number_arg |
none |
task_id |
^TASK-[0-9]+$ |
add_task_id_arg |
none |
component |
^[a-z0-9-]+(:[a-z0-9-]+)*$ |
add_component_arg |
none |
hash_id |
^[a-f0-9]{4,}$ |
add_hash_id_arg |
none |
phase_id |
^[1-6]-(init|refine|outline|plan|execute|finalize)$ |
add_phase_arg |
none |
field_name |
^[a-z][a-z0-9_]*$ |
add_field_arg |
none |
module_name |
^[a-z][a-z0-9_-]*$ |
add_module_arg |
none |
package_name |
^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$ |
add_package_arg |
none |
domain_name |
^[a-z][a-z0-9-]*$ |
add_domain_arg |
none |
resource_name |
^[a-zA-Z0-9_-]+$ |
add_name_arg |
none |
The NO_PLAN sentinel (plan_id carve-out)
plan_id carries exactly one carve-out: the plan-less sentinel NO_PLAN, exported as NO_PLAN_SENTINEL.
The literal is defined once in script-shared's marketplace_paths.py and re-exported here. That placement is load-bearing, not incidental: file_ops also needs the sentinel, and file_ops must import cleanly on the bootstrap path — bootstrap_plugin.py and its siblings run before the executor sets PYTHONPATH and put only script-shared, ref-toon-format and tools-file-ops on sys.path, with tools-input-validation deliberately absent. Importing the sentinel from input_validation at file_ops module scope therefore breaks every bootstrap import with ModuleNotFoundError. Import it from either surface; both name the same object.
| Sentinel | Value | Accepted by | Matched by PLAN_ID_RE |
|---|---|---|---|
NO_PLAN_SENTINEL |
NO_PLAN |
validate_plan_id / is_valid_plan_id |
No — the regex still rejects it |
The uppercase spelling is deliberate: it is unrepresentable in the kebab-case grammar, so no real plan id can ever collide with it. The carve-out is evaluated ahead of the PLAN_ID_RE test inside validate_plan_id, which is the single canonical place it exists — PLAN_ID_RE itself is unchanged, and the carve-out MUST NOT be duplicated into any caller. is_valid_plan_id inherits it by delegation.
Routing a caller through NO_PLAN is correct only when that caller genuinely has no plan (the steward landing cycle and other plan-less entry points). A mistyped real plan id must be corrected, never substituted with the sentinel. This is why a plan_not_found failure surfaces the sentinel as a hint paired with a hint_caveat — the hint alone would turn every typo into a silent write against the shared sentinel directory.
Validation is not resolution. This skill owns only the CLI boundary: validate_plan_id decides whether a string is an acceptable plan_id, and stops there. Turning an accepted plan_id into a plan directory and a working-tree root is the job of the single resolution entry point, file_ops.resolve_plan_context — it resolves the sentinel to {base_dir}/plans/NO_PLAN, materializes it on demand under ensure=True (directory and status.json), and always presents the main checkout as its worktree face. Every plan-id consumer resolves through that function, including script-shared's resolve_project_dir, which delegates to it rather than resolving a worktree itself. See `tools-file-ops/SKILL.md` § "Plan-Context Resolution" for the resolver contract.
The constants are exported from input_validation.py as PLAN_ID_RE, LESSON_ID_RE, etc., so consumers can reuse the canonical regex without re-deriving it. The add_<id>_arg(parser) builders wire type=validate_<id> into argparse so malformed input is rejected at the CLI boundary; pair them with parse_args_with_toon_errors() to centralise the status: error / error: invalid_<field> output path.
Edge-case contract
A handful of canonical regexes intentionally reject inputs that pre-canonical scripts accepted — these are current, deliberate behaviours documented in standards/identifier-validation-audit.md:
COMPONENT_RErejects uppercase / path-shaped Sonar component keys (e.g.org:src/Main.java) at the CLI boundary.--task-numbervalidates againstTASK_NUMBER_REthen coerces toint, so downstream consumers still receive an int.--lesson-idis repeatable (action='append'); each element is validated independently and any malformed value fails the whole invocation.
When adding a new identifier-shaped flag, use the matching add_<id>_arg(parser) builder and add a row to the registry table above — including its Carve-out cell, which reads none unless the validator genuinely accepts a value the regex rejects. Pair the builders with parse_args_with_toon_errors() for the canonical status: error / error: invalid_<field> output path.
Python Usage
from input_validation import is_valid_plan_id, validate_enum
# Plan ID validation: kebab-case per PLAN_ID_RE (leading lowercase letter, then
# lowercase letters / digits / hyphens; no length bound), plus the NO_PLAN carve-out
if not is_valid_plan_id(args.plan_id):
output_error('invalid_plan_id', f'Invalid plan_id format: {args.plan_id}')
sys.exit(1)
# Enum validation
validate_enum(args.certainty, ['CERTAIN_INCLUDE', 'CERTAIN_EXCLUDE', 'UNCERTAIN'], 'certainty')Module: schema_validation.py
Location: scripts/schema_validation.py
Lightweight schema validation for plan-marshall JSON storage files. Returns a list of error strings (empty list = valid). No external dependencies.
Functions
1. validate_status(data: Any) -> list[str]
- Purpose: Validate
status.json— requiresplan_id(str),current_phase(str),phases(list of dicts withnameandstatus)
2. validate_references(data: Any) -> list[str]
- Purpose: Validate
references.json— requiresplan_id(str)
3. validate_task(data: Any) -> list[str]
- Purpose: Validate
TASK-*.json— requirestask_id(str),title(str),status(str),steps(list of dicts withidandtitle)
4. validate_assessment(data: Any) -> list[str]
- Purpose: Validate assessment records — requires
hash_id(str),file_path(str),certainty(str, one of CERTAIN_INCLUDE/CERTAIN_EXCLUDE/UNCERTAIN),confidence(int or float)
5. validate_finding(data: Any) -> list[str]
- Purpose: Validate finding records — requires
hash_id(str),type(str),severity(str),message(str)
Schema Validation Usage
from schema_validation import validate_status, validate_task
errors = validate_status(data)
if errors:
output_error('schema_violation', '; '.join(errors))
sys.exit(1)Usage Examples
# Drop-in replacement for existing validate_plan_id pattern
from input_validation import is_valid_plan_id
if not is_valid_plan_id(args.plan_id):
print(f'Error: Invalid plan_id format: {args.plan_id}', file=sys.stderr)
sys.exit(1)
# New code: raising style with chaining
from input_validation import validate_plan_id, validate_relative_path
plan_id = validate_plan_id(args.plan_id) # raises ValueError
file_path = validate_relative_path(args.file) # raises ValueError
# Enum validation
from input_validation import validate_enum
validate_enum(args.status, ['pending', 'done', 'blocked'], 'status')