You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug]: specify init writes speckit.manifest.json without the speckit-converge skill it just installed #4273
spec-kit v1.0.1, specify init --here --integration claude --force
Description
After an in-place upgrade, .specify/integrations/speckit.manifest.json lists 9 speckit-* SKILL.md entries under files — but init also installed .claude/skills/speckit-converge/SKILL.md, which is absent from the manifest.
Any tooling that uses the manifest to classify vendored files (pristine / modified / missing / untracked — e.g. deciding whether the next specify init will clobber local work) sees converge as untracked: it cannot be classified at all, which is the worst of the four states for a file the installer itself owns.
Observed identically on two independent upgrades (a template repo and a workflows repo, both 0.5.0 → v1.0.1 in-place).
Expected behavior
The generated manifest includes every skill file the init run installs, converge included.
Hi, I’d like to investigate the missing speckit-converge entry in the manifest generated by init. I can reproduce it with a clean installation, add a regression test for the generated manifest, and make the smallest fix that keeps existing manifests compatible. Is this issue open for an external PR?
After running specify init --here --integration claude --force with spec-kit v1.0.1 (upgrading from v0.5.0), the file .specify/integrations/claude.manifest.json (referred to as speckit.manifest.json in the report — see note below) contains 9 speckit-* SKILL.md entries but is missing speckit-converge/SKILL.md, even though that file was successfully installed at .claude/skills/speckit-converge/SKILL.md. Observed on two independent in-place upgrades.
Note on filename: .specify/integrations/speckit.manifest.json is the shared infrastructure manifest (tracks scripts/templates). The per-integration manifest for claude lives at .specify/integrations/claude.manifest.json. Based on the description ("9 speckit-* SKILL.md entries"), the reporter is looking at claude.manifest.json and using "speckit.manifest.json" generically.
Symptom
specify init --here --integration claude --force installs all 10 speckit-* SKILL.md files to .claude/skills/ (including speckit-converge/SKILL.md) but the resulting claude.manifest.json records only 9 of them — speckit-converge/SKILL.md is absent. Any tooling that consults the manifest to classify files (pristine / modified / missing / untracked) will treat speckit-converge/SKILL.md as untracked, preventing clean uninstall, upgrade diff-detection, and hash-based freshness checks for that skill.
Reproduction
Create a project with spec-kit v0.5.0: specify init myproject --integration claude
Upgrade the CLI to v1.0.1.
From inside myproject/, run specify init --here --integration claude --force.
Inspect .specify/integrations/claude.manifest.json — check files keys for speckit-*/SKILL.md entries.
Observe that .claude/skills/speckit-converge/SKILL.md exists on disk but has no corresponding entry in the manifest.
[NEEDS CLARIFICATION: Whether the same omission occurs on a freshspecify init (no prior install) with v1.0.1 or only on --force re-inits of existing projects.]
Suspected Code Paths
src/specify_cli/integrations/base.py:1725–1818 (SkillsIntegration.setup() loop) — the main loop that calls write_file_and_record() for each skill. Converge is index 4 of _CORE_COMMAND_TEMPLATE_ORDER (line 49); the loop should process all 10 templates without exclusion.
src/specify_cli/integrations/base.py:558–576 (write_file_and_record()) — writes the file, then calls manifest.record_existing(rel). If record_existing raises (e.g. for a symlinked path component) and this exception is swallowed somewhere, the file is on disk but not in the manifest.
src/specify_cli/integrations/manifest.py:168–233 (record_existing()) — raises ValueError if any path component traversed from self.project_root is a symlink, or if the file is not a regular file. This is a plausible silent-failure site if an outer context swallows the exception for one file.
src/specify_cli/commands/init.py:665–698 — the init entry point for integration setup. Creates a fresh IntegrationManifest, calls setup(), then manifest.save(). After manifest.save() (line 698), _register_extensions_for_agent and _register_presets_for_agent are called when --force is set (lines 700–722). These post-save calls do not affect the already-saved integration manifest but they do run after the file is saved, creating a potential window.
src/specify_cli/integrations/base.py:466–479 (list_command_templates()) — lists all .md files from shared_commands_dir(). Converge is present in templates/commands/converge.md and would be returned; the sort key is distinct (_CORE_COMMAND_TEMPLATE_RANK["converge"] == 4). No exclusion here.
Root Cause Hypothesis
Converge was introduced in commit 0c29d890 (feat: add /speckit.converge command, June 2026), making it the most recently added core command when v1.0.1 shipped. The SkillsIntegration.setup() loop has no try/except around the per-skill write; any exception would abort setup entirely, not silently skip one file. This rules out most exception-swallowing theories.
The most credible scenario is an environmental interaction specific to the upgrade path: on a v0.5.0 project, the .claude/skills/ directory existed with 9 subdirectories pre-installed. When specify init --force runs setup() over an already-populated tree, dest.parent.mkdir(parents=True, exist_ok=True) silently succeeds for all 10 skill directories. For converge specifically, if the newly created speckit-converge/ directory resolves differently (e.g., a stale inode, platform-specific casing, or a symlink left by a prior install) and manifest.record_existing() subsequently raises ValueError for that path after the file is written, then...
However, setup() does not have a per-iteration try/except — a ValueError from record_existing would propagate to init.py's outer try/except Exception handler (line 936), which would print an error and exit. The user would not see a "success with 9 files" result. This means the exception-path theory requires a currently-invisible swallowing site.
Most likely: The bug is a missing coverage gap — write_file_and_record calls manifest.record_existing(rel) where rel is computed as dest.resolve().relative_to(project_root.resolve()) (a Path object). record_existing re-walks from manifest.project_root to verify no symlinks. If project_root passed to write_file_and_record differs from manifest.project_root (even by trailing slash or symlink resolution), the reconstructed absolute path for converge's SKILL.md may not match what was written, so _sha256(abs_path) operates on a slightly different path. The save() then writes only the keys that were successfully recorded.
Confidence: medium. The code path is clear, but reproducing it requires the specific v0.5.0→v1.0.1 upgrade environment. A targeted regression test would either confirm or rule out this theory.
Proposed Remediation
Preferred: Add an explicit assertion / test that verifies the count of manifest entries equals the count of installed SKILL.md files after setup() in both fresh-install and re-init (--force) scenarios. Then add the same check inside SkillsIntegration.setup() as a postcondition assertion (or a warning log) that counts len([f for f in created if f.name == "SKILL.md"]) against len(manifest.files filtered to SKILL.md paths). If they diverge, raise so the bug surfaces loudly rather than producing a silently-partial manifest.
Additionally, in write_file_and_record, replace the manifest.record_existing(rel) call with the more direct manifest.record_file(rel, ...) pattern (which writes content and records in one step, computing the hash from the in-memory bytes rather than re-reading from disk). This eliminates the window between write and record and avoids any potential path-resolution discrepancy.
Alternative: Add a post-setup reconciliation step in SkillsIntegration.setup() after the loop: iterate skills_dir.rglob("SKILL.md"), and for any SKILL.md found under a speckit-* directory that is NOT yet in the manifest, call manifest.record_existing() on it. This is a belt-and-suspenders backstop that self-heals silently omitted entries.
Files likely to change:
src/specify_cli/integrations/base.py (SkillsIntegration.setup loop — add postcondition or switch to record_file)
tests/integrations/test_integration_base_skills.py (add a test that verifies exactly N SKILL.md entries appear in manifest.files, exercising re-init over a pre-populated skills directory)
tests/integrations/test_integration_claude.py (add the same assertion for the Claude-specific subclass)
Tests to add or update:
A test that pre-populates a tmp_path with 9 SKILL.md stubs under .claude/skills/speckit-*/ (simulating an existing v0.5.0 install without converge) and then calls setup() with a fresh manifest — verifies all 10 skills including converge appear in manifest.files after setup().
A test that calls setup() twice with a fresh manifest each time (simulating --force reinit) and asserts both manifest objects record the same 10 entries.
Extend test_all_files_tracked_in_manifest to also check the inverse: that len([k for k in m.files if k.endswith("SKILL.md")]) equals len(templates) (currently the test only checks files in created are tracked, not that ALL templates produced entries).
Risks & Considerations
Uninstall breakage: specify integration uninstall claude will not remove speckit-converge/SKILL.md because it is not in the manifest. The directory .claude/skills/speckit-converge/ is also not removed. This is already occurring for any project that hit this bug.
Upgrade stale-file pass: The integration upgrade stale-file pass (Phase 2 in _migrate_commands.py:844–867) computes stale_keys = old_files - new_files. If the old manifest is missing converge, the stale set will never include converge — so a subsequent upgrade won't try to remove a stale converge skill. This is benign but means the broken state persists silently across upgrades.
No data loss: The converge skill file is fully intact on disk; only the manifest entry is missing. Recovery is manual: re-run specify integration upgrade claude or specify init --here --force once the bug is fixed.
Blast radius: Affects every skills-based integration (not just claude) that ran specify init --force over a pre-existing project after converge was added in v1.0.1.
Open Questions
[NEEDS CLARIFICATION: Does a fresh specify init (no prior .claude/skills/ directory) on v1.0.1 also omit converge, or is it strictly a --force/upgrade-path issue?]
[NEEDS CLARIFICATION: Is the missing entry consistently the converge skill, or could it be whichever skill happens to fall at position 4 in the template order? If the issue is positional (e.g., an off-by-one in a loop boundary), earlier or later commands would also be affected.]
Posted on behalf of @mnriem by GitHub Copilot (model: claude-sonnet-4.6)
Draft fix PR created on fix/4273-converge-missing-manifest: converge-missing-manifest now records generated skill files atomically and verifies every SKILL.md is present in the manifest. Please review the draft and validate it in the stage-3 bug-test workflow, if configured.
Activity
Gambit-Checkmate commentedon Aug 22, 2026
Hi, I’d like to investigate the missing speckit-converge entry in the manifest generated by init. I can reproduce it with a clean installation, add a regression test for the generated manifest, and make the smallest fix that keeps existing manifests compatible. Is this issue open for an external PR?
mnriem commentedon Sep 1, 2026
@Gambit-Checkmate Absolutely, go for it. I have triggered our bug assess pipeline so you will get some additional background
github-actions commentedon Sep 1, 2026
Bug assessment — converge-missing-manifest: Valid · severity medium
Bug Assessment: converge-missing-manifest
converge-missing-manifestReport (summarized)
After running
specify init --here --integration claude --forcewith spec-kit v1.0.1 (upgrading from v0.5.0), the file.specify/integrations/claude.manifest.json(referred to asspeckit.manifest.jsonin the report — see note below) contains 9speckit-*SKILL.md entries but is missingspeckit-converge/SKILL.md, even though that file was successfully installed at.claude/skills/speckit-converge/SKILL.md. Observed on two independent in-place upgrades.Note on filename:
.specify/integrations/speckit.manifest.jsonis the shared infrastructure manifest (tracks scripts/templates). The per-integration manifest for claude lives at.specify/integrations/claude.manifest.json. Based on the description ("9 speckit-* SKILL.md entries"), the reporter is looking atclaude.manifest.jsonand using "speckit.manifest.json" generically.Symptom
specify init --here --integration claude --forceinstalls all 10speckit-*SKILL.md files to.claude/skills/(includingspeckit-converge/SKILL.md) but the resultingclaude.manifest.jsonrecords only 9 of them —speckit-converge/SKILL.mdis absent. Any tooling that consults the manifest to classify files (pristine / modified / missing / untracked) will treatspeckit-converge/SKILL.mdas untracked, preventing clean uninstall, upgrade diff-detection, and hash-based freshness checks for that skill.Reproduction
specify init myproject --integration claudemyproject/, runspecify init --here --integration claude --force..specify/integrations/claude.manifest.json— checkfileskeys forspeckit-*/SKILL.mdentries..claude/skills/speckit-converge/SKILL.mdexists on disk but has no corresponding entry in the manifest.[NEEDS CLARIFICATION: Whether the same omission occurs on a fresh
specify init(no prior install) with v1.0.1 or only on--forcere-inits of existing projects.]Suspected Code Paths
src/specify_cli/integrations/base.py:1725–1818(SkillsIntegration.setup()loop) — the main loop that callswrite_file_and_record()for each skill. Converge is index 4 of_CORE_COMMAND_TEMPLATE_ORDER(line 49); the loop should process all 10 templates without exclusion.src/specify_cli/integrations/base.py:558–576(write_file_and_record()) — writes the file, then callsmanifest.record_existing(rel). Ifrecord_existingraises (e.g. for a symlinked path component) and this exception is swallowed somewhere, the file is on disk but not in the manifest.src/specify_cli/integrations/manifest.py:168–233(record_existing()) — raisesValueErrorif any path component traversed fromself.project_rootis a symlink, or if the file is not a regular file. This is a plausible silent-failure site if an outer context swallows the exception for one file.src/specify_cli/commands/init.py:665–698— the init entry point for integration setup. Creates a freshIntegrationManifest, callssetup(), thenmanifest.save(). Aftermanifest.save()(line 698),_register_extensions_for_agentand_register_presets_for_agentare called when--forceis set (lines 700–722). These post-save calls do not affect the already-saved integration manifest but they do run after the file is saved, creating a potential window.src/specify_cli/integrations/base.py:466–479(list_command_templates()) — lists all.mdfiles fromshared_commands_dir(). Converge is present intemplates/commands/converge.mdand would be returned; the sort key is distinct (_CORE_COMMAND_TEMPLATE_RANK["converge"] == 4). No exclusion here.Root Cause Hypothesis
Converge was introduced in commit
0c29d890(feat: add /speckit.converge command, June 2026), making it the most recently added core command when v1.0.1 shipped. TheSkillsIntegration.setup()loop has no try/except around the per-skill write; any exception would abort setup entirely, not silently skip one file. This rules out most exception-swallowing theories.The most credible scenario is an environmental interaction specific to the upgrade path: on a v0.5.0 project, the
.claude/skills/directory existed with 9 subdirectories pre-installed. Whenspecify init --forcerunssetup()over an already-populated tree,dest.parent.mkdir(parents=True, exist_ok=True)silently succeeds for all 10 skill directories. For converge specifically, if the newly createdspeckit-converge/directory resolves differently (e.g., a stale inode, platform-specific casing, or a symlink left by a prior install) andmanifest.record_existing()subsequently raisesValueErrorfor that path after the file is written, then...However,
setup()does not have a per-iteration try/except — aValueErrorfromrecord_existingwould propagate toinit.py's outertry/except Exceptionhandler (line 936), which would print an error and exit. The user would not see a "success with 9 files" result. This means the exception-path theory requires a currently-invisible swallowing site.Most likely: The bug is a missing coverage gap —
write_file_and_recordcallsmanifest.record_existing(rel)whererelis computed asdest.resolve().relative_to(project_root.resolve())(aPathobject).record_existingre-walks frommanifest.project_rootto verify no symlinks. Ifproject_rootpassed towrite_file_and_recorddiffers frommanifest.project_root(even by trailing slash or symlink resolution), the reconstructed absolute path for converge'sSKILL.mdmay not match what was written, so_sha256(abs_path)operates on a slightly different path. Thesave()then writes only the keys that were successfully recorded.Confidence: medium. The code path is clear, but reproducing it requires the specific v0.5.0→v1.0.1 upgrade environment. A targeted regression test would either confirm or rule out this theory.
Proposed Remediation
Preferred: Add an explicit assertion / test that verifies the count of manifest entries equals the count of installed SKILL.md files after
setup()in both fresh-install and re-init (--force) scenarios. Then add the same check insideSkillsIntegration.setup()as a postcondition assertion (or a warning log) that countslen([f for f in created if f.name == "SKILL.md"])againstlen(manifest.files filtered to SKILL.md paths). If they diverge, raise so the bug surfaces loudly rather than producing a silently-partial manifest.Additionally, in
write_file_and_record, replace themanifest.record_existing(rel)call with the more directmanifest.record_file(rel, ...)pattern (which writes content and records in one step, computing the hash from the in-memory bytes rather than re-reading from disk). This eliminates the window between write and record and avoids any potential path-resolution discrepancy.Alternative: Add a post-setup reconciliation step in
SkillsIntegration.setup()after the loop: iterateskills_dir.rglob("SKILL.md"), and for any SKILL.md found under aspeckit-*directory that is NOT yet in the manifest, callmanifest.record_existing()on it. This is a belt-and-suspenders backstop that self-heals silently omitted entries.Files likely to change:
src/specify_cli/integrations/base.py(SkillsIntegration.setup loop — add postcondition or switch torecord_file)tests/integrations/test_integration_base_skills.py(add a test that verifies exactly N SKILL.md entries appear in manifest.files, exercising re-init over a pre-populated skills directory)tests/integrations/test_integration_claude.py(add the same assertion for the Claude-specific subclass)Tests to add or update:
tmp_pathwith 9 SKILL.md stubs under.claude/skills/speckit-*/(simulating an existing v0.5.0 install without converge) and then callssetup()with a fresh manifest — verifies all 10 skills including converge appear inmanifest.filesaftersetup().setup()twice with a fresh manifest each time (simulating--forcereinit) and asserts both manifest objects record the same 10 entries.test_all_files_tracked_in_manifestto also check the inverse: thatlen([k for k in m.files if k.endswith("SKILL.md")])equalslen(templates)(currently the test only checks files increatedare tracked, not that ALL templates produced entries).Risks & Considerations
specify integration uninstall claudewill not removespeckit-converge/SKILL.mdbecause it is not in the manifest. The directory.claude/skills/speckit-converge/is also not removed. This is already occurring for any project that hit this bug.integration upgradestale-file pass (Phase 2 in_migrate_commands.py:844–867) computesstale_keys = old_files - new_files. If the old manifest is missing converge, the stale set will never include converge — so a subsequent upgrade won't try to remove a stale converge skill. This is benign but means the broken state persists silently across upgrades.specify integration upgrade claudeorspecify init --here --forceonce the bug is fixed.specify init --forceover a pre-existing project after converge was added in v1.0.1.Open Questions
specify init(no prior.claude/skills/directory) on v1.0.1 also omit converge, or is it strictly a--force/upgrade-path issue?]Posted on behalf of
@mnriemby GitHub Copilot (model: claude-sonnet-4.6)Fix converge-missing-manifest: track generated skill files
github-actions commentedon Oct 15, 2026
Pull request created: #4990
github-actions commentedon Oct 15, 2026
Draft fix PR created on
fix/4273-converge-missing-manifest: converge-missing-manifest now records generated skill files atomically and verifies everySKILL.mdis present in the manifest. Please review the draft and validate it in the stage-3bug-testworkflow, if configured.Assisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous)