v0.17.0 - Classification Frontmatter Field and Confluence Update Tool
Added
-
create_feat(feat domain) now accepts an optional, caller-chosen
id: str | None = Noneparameter — a full, well-formedfeat-NNN-slug
value, validated viaassert_feat_id(general/tools/_path_safety.py)
before any lock/filesystem access. Whenidis omitted, the default is
nowfeat-0-<slug-from-title>— the previousfeat-{max existing NNN + 1}-{slug}auto-increment fallback is gone entirely, sinceNNNis
meant to be the GitHub issue number a feature tracks, andfeat-0-...
now signals "no issue yet" rather than a scan-derived guess. Either way
(caller-supplied or defaulted),create_featraisesFileExistsError
before any write if the resulting id/folder already exists, and raises
ValueErrorbefore any write if a caller-suppliediddoesn't match the
feat-NNN-slugshape. A newset_feat_id(id, new_id)@mcp.tool()
(feat domain,feat/tools/set_feat_id.py) complements this by letting an
existing feature's id be renamed afterwards (e.g. once its GitHub issue
number becomes known): it validatesnew_id's shape, refuses via
FileExistsErrorif the target folder already exists, renames
<base>/<id>/to<base>/<new_id>/, rewrites the frontmatteridand
bumpsupdated, leaves the body byte-identical, and raises
FeatNotFoundErrorifiddoes not resolve. It runs under
feat_create_lock()(outermost) thenfeat_lock(id)(nested) to avoid
races withcreate_feat/update/set_status/deleteon the same id.
featremains dispatch-only for whole-body updates/status changes — no
update_feat/set_status_feattool of its own;set_feat_idis a
distinct, bespoke tool for id changes specifically (GitHub issue #48). -
Windowed raw reads on the eleven
get_<d>MCP tools
(req/uc/tsk/qa/prb/gol/rsk/dec/sop/feat/vcr):
each now accepts optional read-styleoffset/limitcoordinates for a
windowed raw read — valid withraw=Trueonly (coordinates with
raw=FalseraiseValueError),offset1-based with default 1
(floored, never errors),limita line count defaulting to through end
of body (capped at the remaining lines), andoffset > Nreturning the
empty string; out-of-range values clamp, consistent with thelist_<d>
paging convention. The window is served by a new no-I/O
window_body(text, offset, limit)helper ingeneral/tools/_splice.py,
besidebody_text/splice_body, so the raw/splice invariant (the line
numbers a client sees in anyget_<d>(raw=True)read, windowed or not,
index byte-for-byte into the same text the genericupdatetool splices
against) is defined once and shared by all eleven tools (GitHub issue
#28; ADR 4ec08dcb-fcb7-4961-abaf-ff7803e2f21d). -
confluence_updateMCP tool (general/tools/): writes a local Markdown
file's rendered content into an existing Confluence page's body via the
REST API, resolving a bare page id, a browsable page URL, or a REST
content URL to a numeric page id,GET-ing the page's current
version/title, rendering the Markdown viamarkdown-it-pyto an
HTML fragment, andPUT-ing the incremented version. Local images
referenced by the Markdown file are uploaded as Confluence attachments
on a best-effort basis (POST .../child/attachment, falling back to
.../child/attachment/{id}/dataif the filename already exists --
duplicate-filename detection is confirmed against a real Confluence
server's actual 400 response, "Cannot add a new attachment with same
file name as an existing attachment:<filename>. Log referral number
is<uuid>") and their<img>tags are rewritten into Confluence's
<ac:image>/<ri:attachment>storage-format macro. Also sanitizes any
raw--sequence inside rendered<!-- -->HTML comments (valid
CommonMark but rejected outright by Confluence's strict XHTML
storage-format parser, confirmed against a real instance:"Error parsing xhtml: String '--' not allowed in comment") and converts a
leading YAML frontmatter block into a fenced code block before
rendering, instead of letting CommonMark's thematic-break/Setext-heading
rules mangle it into a stray<h2>heading (also confirmed against a
real instance). Closes GitHub issue #50, per ADR
a156fdf9-052c-4f43-93a2-eeec04a91eac. -
confluence_update/confluence_fetchMCP prompts (general/prompts/):
thin, single-tool-call prompts sharing their respective tools' exact
names (a separate MCP registry from tools). Each returns instructional
text telling the LLM to call the matchingconfluence_update/
confluence_fetchtool with the given parameters -- neither prompt ever
calls its tool itself.confluence_updatealso tells the LLM to report
back the tool's returnedversion/failed_images;confluence_fetch
documents thatdestination_pathis only required for binary/non-text
content. Part of feat-50-confluence Phase 8, REQ-012/REQ-013. -
Optional, free-text
classificationfrontmatter field on the shared
MarkdownFrontmatterbase, inherited by all eleven whole-body domains
(req/uc/tsk/qa/prb/gol/rsk/dec/sop/feat/vcr; ADR
excluded, since it has its own separate frontmatter model). A new
genericset_classification(id, type, classification)MCP tool
(general/tools/) mirrorsset_status's dispatch pattern to change it
after creation, bumpingupdatedand leaving the body and every other
frontmatter field untouched; a blank/whitespace-only value clears
classificationback toNone/absent, same as every other optional
frontmatter field's blank-to-Nonenormalization. Existing documents
without aclassificationkey keep parsing unchanged. The ten
whole-body domains' packaged create/update prompt instruction files
(uc, which has no prompts sub-package yet, is untouched) now mention
set_classificationalongside the existingset_statusmentions
(GitHub issue #56).
Changed
-
BREAKING (0.x): the generic
updateMCP tool's 1-based inclusive
begin/endbody-line range (with theN+1end-of-body sentinel) is
replaced by read-styleoffset/limitcoordinates in a hard rename (no
compatibility alias):offsetis the 1-based first line to replace
(allowed1..N+1, whereN+1is the virtual end-of-body append
position),limitis the number of lines (offset..offset+limit-1);
omittedlimitreplaces through the last body line,limit=0is a pure
insert. Out-of-range coordinates raiseValueError(strict, never
clamped, nothing written) andlimitwithoutoffsetraises
ValueErrorbefore any file access; splice-then-validate-whole, verbatim
persistence, and frontmatter carry-over are unchanged. Every LLM-facing
surface (the packaged prompt instruction files, tool descriptions,
docstrings,AGENTS.md) moved to the new vocabulary in this same
release. The revised contract is recorded in ADR
4ec08dcb-fcb7-4961-abaf-ff7803e2f21d (referencing, not superseding, ADR
36905d5b-8057-4294-8665-c7eed5534db0) (GitHub issue #28). -
Eliminated all 42 pylint W0622 (redefined-builtin) findings via 39
explicit, per-file# pylint: disable=redefined-builtincomments (with
a one-line rationale) on the files whose public API intentionally uses
id/typeas parameter names — the twelveget_<d>tools, the
per-domain update/implement prompts, the ADR tools/resources/prompts,
the genericupdate/set_status/deletepublic functions, and
models/md/markdown.py/alias.py. Not breaking — no behavior change,
purely an internal lint-suppression change. No globalpyproject.toml
pylint configuration change: a future file that shadows a builtin
without adding its own disable comment still warns (GitHub issue #41,
Phase 5 of feat-38-39-41-43-44). -
The twelve
get_<d>tools (includingget_adr), the genericupdate
tool, and the genericset_statustool now validateidfor
path-injection/wrong-format before any filesystem access, and confine
the resolved path to the domain's own base directory after resolution —
the samegeneral.tools._path_safetyguards the genericdeletetool
already had (feat-36-delete)._path_safety.validate_idnow also
accepts"adr"as a UUID-shaped domain. This is purely additive
validation: a previously well-formed id for its domain is unaffected; a
path-injection attempt or a malformed id — which would already have
failed downstream (e.g. via aFileNotFoundError/XNotFoundError) —
now fails earlier and more explicitly with aValueError.delete
itself is unchanged (GitHub issue #43, Phase 4 of
feat-38-39-41-43-44). -
BREAKING: renamed the
webfetchMCP tool toconfluence_fetch(and
its environment variablesSPECMGR_WEBFETCH_BASE_URL/
SPECMGR_WEBFETCH_BEARERtoSPECMGR_CONFLUENCE_BASE_URL/
SPECMGR_CONFLUENCE_BEARER); part of feat-50-confluence. Beyond the
rename,confluence_fetchnow auto-converts browsable Confluence page
URLs (Cloud-style/pages/<id>/<title>and Server-style
?pageId=<id>) into the equivalent
{base}/rest/api/content/{id}?expand=body.storageREST API URL before
fetching, rejects the/x/<tinyid>tiny-link URL shape with a clear
error (unresolvable to a page id without an authenticated browser
session), detects when a request is redirected off the configured base
URL's host (e.g. to an SSO login page) and raises instead of returning
that content, and supports binary/image download via a
destination_pathparameter (content-type based). GitHub issue #50,
ADR a156fdf9-052c-4f43-93a2-eeec04a91eac. -
BREAKING: frontmatter
created/updatednow strictly require the
date+time variantyyyy-MM-dd HH:mm:ss.ffffollowed byZ(UTC) or a
signed±HH:mmoffset — date-only,T-separated, six-digit-microsecond,
and timezone-less values are all rejected at parse time
(pydantic.ValidationError), eagerly, on the shared
MarkdownFrontmatterbase every one of the eleven whole-body domains'
frontmatter subclasses inherits from. Every tool-writtencreated/
updatedvalue — across all 11 whole-bodycreate_<d>tools, the 22
genericupdateadapter sites, and the 11 genericset_statusadapter
sites — is now produced by one shared helper
(general.tools._timestamps.now_timestamp()) that guarantees this exact
shape (local time viadatetime.now().astimezone(),Zwhen the UTC
offset is exactly zero, milliseconds truncated to exactly three digits).
ADR frontmatter is unaffected (it has nocreated/updatedfields).
Existing documents with a non-conforming frontmatter timestamp must be
migrated to the new shape before they will parse again — the repo's own
artifacts (the release SOP, the twodocs/tskdocuments, every
.specmgr/feat/*/README.md's frontmatter, and every packaged
template/example) were migrated as part of this change (GitHub issue
#44, Phase 3 of feat-38-39-41-43-44). -
BREAKING: SOP
## Updates, DEC## Updates, VCR## Updates, and
TSK## Recent Updatesnow enforce newest-first ordering at parse
time — an entry whose timestamp precedes (is older than) the entry
above it fails to parse (AssertionError/ValidationError), eagerly,
at construction time. Consecutive entries are compared with an aware
datetimecomparison; when either side is a bare date (no time
component) the comparison happens at day granularity, so a date-only
entry and a same-day date+time entry are treated as equal, and equal
timestamps are always allowed (non-strict "newest-first"). DEC/VCR/TSK
update-entry headings, previously free-form, are now themselves
timestamp-led:### {yyyy-MM-dd or yyyy-MM-dd HH:mm:ss.fff±HH:mm/Z} ( - | : ) {title}, mirroring SOP/FEAT's existing shape — a heading
that does not start with a valid date fails to parse. The SOP/DEC/TSK
update containers gained leading-HTML-comment support (promoted from
MarkdownSection2toMarkdownSection2WithComment; VCR already had
it), so all four now carry a<!-- Newest entry first -- prepend new entries directly below this comment. -->ordering hint in their
packaged templates, and the create/update instructions of all four
domains now direct prepending new entries instead of appending them.
Existing out-of-order or non-timestamp-led SOP/DEC/VCR/TSK documents
must be migrated to the newest-first, timestamp-led shape before they
will parse again (GitHub issue #39, Phase 2 of
feat-38-39-41-43-44). -
BREAKING: update-entry headings no longer accept an em-dash (
—)
separator between the timestamp and the title. SOP## Updates
(### {timestamp} - {title}/### {timestamp} : {title}) and FEAT
## Updates/### Decisions Made(#### {timestamp} - {title}/
#### {timestamp} : {title}) now only accept" - "(space, hyphen,
space) or" : "(space, colon, space) as the separator; an em-dash
entry fails to parse (AssertionError/alias mismatch), eagerly, at
construction time. Existing SOP/FEAT documents using the em-dash
separator must be migrated to" - "or" : "(GitHub issue #38,
Phase 1 of feat-38-39-41-43-44). DEC/VCR update-entry convention text
(docstrings, packaged templates/examples/create-instructions) was
updated to the same separators for consistency, though those two
domains'## Updatesheadings remain free-form and unenforced until
Phase 2.
Fixed
specmgr docs: stale per-module API pages are now pruned.
_generate_api_docsonly ever wrote pages, so a module removed from
src/left itsdocs/api/*.mdpage behind forever — an orphaned file
no longer linked by the regeneratedapi/README.mdindex. It now
deletes every flat*.mdfile in the outputapi/directory that is
neither theREADME.mdindex nor a page written by the same run —
never touchingREADME.md, non-.mdfiles, or nested directories.
Pruning is skipped entirely rather than deleting the existing tree on
any untrustworthy run (zero pages written, any module import failure,
or truncated module collection), so a partial-import environment can
never wipe the tree.docs()echoes✓ Pruned {n} stale page(s) from {api_dir}only when n > 0 (unchanged-tree output stays unchanged) plus
a one-line⚠warning when pruning was skipped due to import
problems. The first run with pruning enabled deleted the five real
stale pages left by feat-13-list-paging's resource→tool conversion
(biz.dfch.specmgr.{adr,qa,req,tsk,uc}.resources.*_list.md) (GitHub
issue #40).- Every validation error surfaced by the
parse_<d>/create_<d>/
validate_<d>MCP tools (all twelve document types) and the generic
update/set_statustools is now actionable instead of a bare,
uninformative message. StructuralAssertionErrors now carry a
document-relative field path (e.g. `Task > RecentUpdates > UpdateEntrycontent
), a 1-based line reference into the mdformat-normalized body plus a snippet of the offending text, and what was expected. For the two triggers that motivated this fix specifically: a bare-style token is rejected as raw HTML with a fix hint ("wrap it in a code span, or write it as an HTML comment"), and a+/-/*-prefixed continuation line gets a cause + fix hint ("this begins a new CommonMark list; remove the marker or indent the line so it belongs to the preceding block instead"). Malformed frontmatter YAML (yaml.YAMLError) now names "the frontmatter block" instead of PyYAML's opaque"", with document-relative (not block-relative) line numbers. Every touched tool additionally prepends its own domain + tool context (e.g."tsk create_tsk (body): ..."). No new exception types and no channel changes throughout — the documented two-channel contract (AssertionErrorstructural /pydantic.
ValidationError` value) is preserved exactly; only message content
changes (GitHub issue #27; subsumes feat-7's not-started Task 0.29).