@pilotspace/add 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +195 -1
- package/README.md +70 -3
- package/THIRD_PARTY_NOTICES.md +41 -0
- package/agents/add-advisor.md +31 -0
- package/agents/add-build.md +29 -0
- package/agents/add-design.md +32 -0
- package/agents/add-persona.md +31 -0
- package/agents/add-verify.md +34 -0
- package/bin/cli.js +615 -54
- package/docs/02-the-flow.md +1 -1
- package/docs/03-step-1-specify.md +4 -0
- package/docs/04-step-2-scenarios.md +4 -0
- package/docs/06-step-4-tests.md +4 -0
- package/docs/08-step-6-verify.md +7 -0
- package/docs/18-personas.md +80 -0
- package/docs/add-milestone-task-lifecycle.png +0 -0
- package/docs/add-task-growth-wheel.png +0 -0
- package/docs/appendix-c-glossary.md +5 -1
- package/package.json +4 -1
- package/personas-teacher/LICENSE +21 -0
- package/personas-teacher/README.md +1067 -0
- package/personas-teacher/VENDOR.md +13 -0
- package/personas-teacher/academic/academic-anthropologist.md +125 -0
- package/personas-teacher/academic/academic-geographer.md +127 -0
- package/personas-teacher/academic/academic-historian.md +123 -0
- package/personas-teacher/academic/academic-narratologist.md +118 -0
- package/personas-teacher/academic/academic-psychologist.md +118 -0
- package/personas-teacher/design/design-brand-guardian.md +322 -0
- package/personas-teacher/design/design-image-prompt-engineer.md +236 -0
- package/personas-teacher/design/design-inclusive-visuals-specialist.md +71 -0
- package/personas-teacher/design/design-persona-walkthrough.md +272 -0
- package/personas-teacher/design/design-ui-designer.md +383 -0
- package/personas-teacher/design/design-ux-architect.md +469 -0
- package/personas-teacher/design/design-ux-researcher.md +329 -0
- package/personas-teacher/design/design-visual-storyteller.md +149 -0
- package/personas-teacher/design/design-whimsy-injector.md +438 -0
- package/personas-teacher/divisions.json +21 -0
- package/personas-teacher/engineering/engineering-ai-data-remediation-engineer.md +211 -0
- package/personas-teacher/engineering/engineering-ai-engineer.md +146 -0
- package/personas-teacher/engineering/engineering-autonomous-optimization-architect.md +107 -0
- package/personas-teacher/engineering/engineering-backend-architect.md +236 -0
- package/personas-teacher/engineering/engineering-cms-developer.md +536 -0
- package/personas-teacher/engineering/engineering-code-reviewer.md +76 -0
- package/personas-teacher/engineering/engineering-codebase-onboarding-engineer.md +173 -0
- package/personas-teacher/engineering/engineering-data-engineer.md +306 -0
- package/personas-teacher/engineering/engineering-database-optimizer.md +176 -0
- package/personas-teacher/engineering/engineering-devops-automator.md +376 -0
- package/personas-teacher/engineering/engineering-drupal-shopping-cart.md +360 -0
- package/personas-teacher/engineering/engineering-email-intelligence-engineer.md +353 -0
- package/personas-teacher/engineering/engineering-embedded-firmware-engineer.md +173 -0
- package/personas-teacher/engineering/engineering-feishu-integration-developer.md +598 -0
- package/personas-teacher/engineering/engineering-filament-optimization-specialist.md +283 -0
- package/personas-teacher/engineering/engineering-frontend-developer.md +225 -0
- package/personas-teacher/engineering/engineering-git-workflow-master.md +84 -0
- package/personas-teacher/engineering/engineering-incident-response-commander.md +444 -0
- package/personas-teacher/engineering/engineering-it-service-manager.md +561 -0
- package/personas-teacher/engineering/engineering-minimal-change-engineer.md +207 -0
- package/personas-teacher/engineering/engineering-mobile-app-builder.md +493 -0
- package/personas-teacher/engineering/engineering-multi-agent-systems-architect.md +600 -0
- package/personas-teacher/engineering/engineering-orgscript-engineer.md +113 -0
- package/personas-teacher/engineering/engineering-prompt-engineer.md +202 -0
- package/personas-teacher/engineering/engineering-rapid-prototyper.md +462 -0
- package/personas-teacher/engineering/engineering-senior-developer.md +176 -0
- package/personas-teacher/engineering/engineering-software-architect.md +112 -0
- package/personas-teacher/engineering/engineering-solidity-smart-contract-engineer.md +522 -0
- package/personas-teacher/engineering/engineering-sre.md +90 -0
- package/personas-teacher/engineering/engineering-technical-writer.md +393 -0
- package/personas-teacher/engineering/engineering-voice-ai-integration-engineer.md +561 -0
- package/personas-teacher/engineering/engineering-wechat-mini-program-developer.md +350 -0
- package/personas-teacher/engineering/engineering-wordpress-shopping-cart.md +346 -0
- package/personas-teacher/examples/README.md +48 -0
- package/personas-teacher/examples/nexus-spatial-discovery.md +852 -0
- package/personas-teacher/examples/workflow-book-chapter.md +55 -0
- package/personas-teacher/examples/workflow-landing-page.md +119 -0
- package/personas-teacher/examples/workflow-startup-mvp.md +155 -0
- package/personas-teacher/examples/workflow-with-memory.md +238 -0
- package/personas-teacher/finance/finance-bookkeeper-controller.md +260 -0
- package/personas-teacher/finance/finance-financial-analyst.md +234 -0
- package/personas-teacher/finance/finance-fpa-analyst.md +263 -0
- package/personas-teacher/finance/finance-investment-researcher.md +272 -0
- package/personas-teacher/finance/finance-tax-strategist.md +239 -0
- package/personas-teacher/game-development/blender/blender-addon-engineer.md +234 -0
- package/personas-teacher/game-development/game-audio-engineer.md +264 -0
- package/personas-teacher/game-development/game-designer.md +167 -0
- package/personas-teacher/game-development/godot/godot-gameplay-scripter.md +334 -0
- package/personas-teacher/game-development/godot/godot-multiplayer-engineer.md +297 -0
- package/personas-teacher/game-development/godot/godot-shader-developer.md +266 -0
- package/personas-teacher/game-development/level-designer.md +208 -0
- package/personas-teacher/game-development/narrative-designer.md +243 -0
- package/personas-teacher/game-development/roblox-studio/roblox-avatar-creator.md +297 -0
- package/personas-teacher/game-development/roblox-studio/roblox-experience-designer.md +305 -0
- package/personas-teacher/game-development/roblox-studio/roblox-systems-scripter.md +325 -0
- package/personas-teacher/game-development/technical-artist.md +229 -0
- package/personas-teacher/game-development/unity/unity-architect.md +271 -0
- package/personas-teacher/game-development/unity/unity-editor-tool-developer.md +310 -0
- package/personas-teacher/game-development/unity/unity-multiplayer-engineer.md +321 -0
- package/personas-teacher/game-development/unity/unity-shader-graph-artist.md +269 -0
- package/personas-teacher/game-development/unreal-engine/unreal-multiplayer-architect.md +313 -0
- package/personas-teacher/game-development/unreal-engine/unreal-systems-engineer.md +310 -0
- package/personas-teacher/game-development/unreal-engine/unreal-technical-artist.md +256 -0
- package/personas-teacher/game-development/unreal-engine/unreal-world-builder.md +273 -0
- package/personas-teacher/gis/gis-3d-scene-developer.md +111 -0
- package/personas-teacher/gis/gis-analyst.md +91 -0
- package/personas-teacher/gis/gis-bim-specialist.md +108 -0
- package/personas-teacher/gis/gis-cartography-designer.md +150 -0
- package/personas-teacher/gis/gis-drone-reality-mapping.md +120 -0
- package/personas-teacher/gis/gis-geoai-ml-engineer.md +105 -0
- package/personas-teacher/gis/gis-geoprocessing-specialist.md +97 -0
- package/personas-teacher/gis/gis-qa-engineer.md +133 -0
- package/personas-teacher/gis/gis-solution-engineer.md +101 -0
- package/personas-teacher/gis/gis-spatial-data-engineer.md +97 -0
- package/personas-teacher/gis/gis-spatial-data-scientist.md +111 -0
- package/personas-teacher/gis/gis-technical-consultant.md +86 -0
- package/personas-teacher/gis/gis-web-gis-developer.md +108 -0
- package/personas-teacher/marketing/marketing-aeo-foundations.md +264 -0
- package/personas-teacher/marketing/marketing-agentic-search-optimizer.md +311 -0
- package/personas-teacher/marketing/marketing-ai-citation-strategist.md +170 -0
- package/personas-teacher/marketing/marketing-app-store-optimizer.md +321 -0
- package/personas-teacher/marketing/marketing-baidu-seo-specialist.md +226 -0
- package/personas-teacher/marketing/marketing-bilibili-content-strategist.md +199 -0
- package/personas-teacher/marketing/marketing-book-co-author.md +110 -0
- package/personas-teacher/marketing/marketing-carousel-growth-engine.md +199 -0
- package/personas-teacher/marketing/marketing-china-ecommerce-operator.md +283 -0
- package/personas-teacher/marketing/marketing-china-market-localization-strategist.md +283 -0
- package/personas-teacher/marketing/marketing-content-creator.md +54 -0
- package/personas-teacher/marketing/marketing-cross-border-ecommerce.md +259 -0
- package/personas-teacher/marketing/marketing-douyin-strategist.md +149 -0
- package/personas-teacher/marketing/marketing-email-strategist.md +249 -0
- package/personas-teacher/marketing/marketing-global-podcast-strategist.md +206 -0
- package/personas-teacher/marketing/marketing-growth-hacker.md +54 -0
- package/personas-teacher/marketing/marketing-instagram-curator.md +113 -0
- package/personas-teacher/marketing/marketing-kuaishou-strategist.md +223 -0
- package/personas-teacher/marketing/marketing-linkedin-content-creator.md +214 -0
- package/personas-teacher/marketing/marketing-livestream-commerce-coach.md +305 -0
- package/personas-teacher/marketing/marketing-multi-platform-publisher.md +217 -0
- package/personas-teacher/marketing/marketing-podcast-strategist.md +277 -0
- package/personas-teacher/marketing/marketing-pr-communications-manager.md +473 -0
- package/personas-teacher/marketing/marketing-private-domain-operator.md +308 -0
- package/personas-teacher/marketing/marketing-reddit-community-builder.md +123 -0
- package/personas-teacher/marketing/marketing-seo-specialist.md +321 -0
- package/personas-teacher/marketing/marketing-short-video-editing-coach.md +412 -0
- package/personas-teacher/marketing/marketing-social-media-strategist.md +125 -0
- package/personas-teacher/marketing/marketing-tiktok-strategist.md +125 -0
- package/personas-teacher/marketing/marketing-twitter-engager.md +126 -0
- package/personas-teacher/marketing/marketing-video-optimization-specialist.md +119 -0
- package/personas-teacher/marketing/marketing-wechat-official-account.md +145 -0
- package/personas-teacher/marketing/marketing-weibo-strategist.md +240 -0
- package/personas-teacher/marketing/marketing-x-twitter-intelligence-analyst.md +161 -0
- package/personas-teacher/marketing/marketing-xiaohongshu-specialist.md +138 -0
- package/personas-teacher/marketing/marketing-zhihu-strategist.md +162 -0
- package/personas-teacher/paid-media/paid-media-auditor.md +71 -0
- package/personas-teacher/paid-media/paid-media-creative-strategist.md +71 -0
- package/personas-teacher/paid-media/paid-media-paid-social-strategist.md +71 -0
- package/personas-teacher/paid-media/paid-media-ppc-strategist.md +71 -0
- package/personas-teacher/paid-media/paid-media-programmatic-buyer.md +71 -0
- package/personas-teacher/paid-media/paid-media-search-query-analyst.md +71 -0
- package/personas-teacher/paid-media/paid-media-tracking-specialist.md +71 -0
- package/personas-teacher/product/product-behavioral-nudge-engine.md +80 -0
- package/personas-teacher/product/product-feedback-synthesizer.md +119 -0
- package/personas-teacher/product/product-manager.md +469 -0
- package/personas-teacher/product/product-sprint-prioritizer.md +154 -0
- package/personas-teacher/product/product-trend-researcher.md +159 -0
- package/personas-teacher/project-management/project-management-experiment-tracker.md +198 -0
- package/personas-teacher/project-management/project-management-jira-workflow-steward.md +230 -0
- package/personas-teacher/project-management/project-management-meeting-notes-specialist.md +95 -0
- package/personas-teacher/project-management/project-management-project-shepherd.md +194 -0
- package/personas-teacher/project-management/project-management-studio-operations.md +200 -0
- package/personas-teacher/project-management/project-management-studio-producer.md +203 -0
- package/personas-teacher/project-management/project-manager-senior.md +135 -0
- package/personas-teacher/sales/sales-account-strategist.md +227 -0
- package/personas-teacher/sales/sales-coach.md +271 -0
- package/personas-teacher/sales/sales-deal-strategist.md +180 -0
- package/personas-teacher/sales/sales-discovery-coach.md +225 -0
- package/personas-teacher/sales/sales-engineer.md +182 -0
- package/personas-teacher/sales/sales-offer-lead-gen-strategist.md +257 -0
- package/personas-teacher/sales/sales-outbound-strategist.md +201 -0
- package/personas-teacher/sales/sales-pipeline-analyst.md +267 -0
- package/personas-teacher/sales/sales-proposal-strategist.md +217 -0
- package/personas-teacher/security/security-appsec-engineer.md +491 -0
- package/personas-teacher/security/security-architect.md +304 -0
- package/personas-teacher/security/security-blockchain-security-auditor.md +463 -0
- package/personas-teacher/security/security-cloud-security-architect.md +523 -0
- package/personas-teacher/security/security-compliance-auditor.md +158 -0
- package/personas-teacher/security/security-incident-responder.md +437 -0
- package/personas-teacher/security/security-penetration-tester.md +399 -0
- package/personas-teacher/security/security-senior-secops.md +750 -0
- package/personas-teacher/security/security-threat-detection-engineer.md +534 -0
- package/personas-teacher/security/security-threat-intelligence-analyst.md +644 -0
- package/personas-teacher/spatial-computing/macos-spatial-metal-engineer.md +337 -0
- package/personas-teacher/spatial-computing/terminal-integration-specialist.md +70 -0
- package/personas-teacher/spatial-computing/visionos-spatial-engineer.md +54 -0
- package/personas-teacher/spatial-computing/xr-cockpit-interaction-specialist.md +32 -0
- package/personas-teacher/spatial-computing/xr-immersive-developer.md +32 -0
- package/personas-teacher/spatial-computing/xr-interface-architect.md +32 -0
- package/personas-teacher/specialized/accounts-payable-agent.md +185 -0
- package/personas-teacher/specialized/agentic-identity-trust.md +387 -0
- package/personas-teacher/specialized/agents-orchestrator.md +367 -0
- package/personas-teacher/specialized/automation-governance-architect.md +216 -0
- package/personas-teacher/specialized/business-strategist.md +488 -0
- package/personas-teacher/specialized/change-management-consultant.md +497 -0
- package/personas-teacher/specialized/chief-financial-officer.md +388 -0
- package/personas-teacher/specialized/corporate-training-designer.md +192 -0
- package/personas-teacher/specialized/customer-service.md +398 -0
- package/personas-teacher/specialized/customer-success-manager.md +460 -0
- package/personas-teacher/specialized/data-consolidation-agent.md +60 -0
- package/personas-teacher/specialized/data-privacy-officer.md +412 -0
- package/personas-teacher/specialized/esg-sustainability-officer.md +396 -0
- package/personas-teacher/specialized/government-digital-presales-consultant.md +363 -0
- package/personas-teacher/specialized/grant-writer.md +511 -0
- package/personas-teacher/specialized/healthcare-customer-service.md +389 -0
- package/personas-teacher/specialized/healthcare-marketing-compliance.md +395 -0
- package/personas-teacher/specialized/hospitality-guest-services.md +603 -0
- package/personas-teacher/specialized/hr-onboarding.md +451 -0
- package/personas-teacher/specialized/identity-graph-operator.md +260 -0
- package/personas-teacher/specialized/language-translator.md +264 -0
- package/personas-teacher/specialized/legal-billing-time-tracking.md +569 -0
- package/personas-teacher/specialized/legal-client-intake.md +492 -0
- package/personas-teacher/specialized/legal-document-review.md +454 -0
- package/personas-teacher/specialized/loan-officer-assistant.md +555 -0
- package/personas-teacher/specialized/lsp-index-engineer.md +314 -0
- package/personas-teacher/specialized/ma-integration-manager.md +427 -0
- package/personas-teacher/specialized/medical-billing-coding-specialist.md +491 -0
- package/personas-teacher/specialized/operations-manager.md +399 -0
- package/personas-teacher/specialized/organizational-psychologist.md +391 -0
- package/personas-teacher/specialized/personal-growth-mentor.md +159 -0
- package/personas-teacher/specialized/real-estate-buyer-seller.md +596 -0
- package/personas-teacher/specialized/recruitment-specialist.md +509 -0
- package/personas-teacher/specialized/report-distribution-agent.md +65 -0
- package/personas-teacher/specialized/retail-customer-returns.md +566 -0
- package/personas-teacher/specialized/sales-data-extraction-agent.md +67 -0
- package/personas-teacher/specialized/sales-outreach.md +425 -0
- package/personas-teacher/specialized/specialized-chief-of-staff.md +279 -0
- package/personas-teacher/specialized/specialized-civil-engineer.md +356 -0
- package/personas-teacher/specialized/specialized-cultural-intelligence-strategist.md +88 -0
- package/personas-teacher/specialized/specialized-developer-advocate.md +317 -0
- package/personas-teacher/specialized/specialized-document-generator.md +55 -0
- package/personas-teacher/specialized/specialized-french-consulting-market.md +192 -0
- package/personas-teacher/specialized/specialized-korean-business-navigator.md +216 -0
- package/personas-teacher/specialized/specialized-mcp-builder.md +248 -0
- package/personas-teacher/specialized/specialized-model-qa.md +488 -0
- package/personas-teacher/specialized/specialized-pricing-analyst.md +243 -0
- package/personas-teacher/specialized/specialized-salesforce-architect.md +180 -0
- package/personas-teacher/specialized/specialized-strategy-duel-agent.md +130 -0
- package/personas-teacher/specialized/specialized-workflow-architect.md +597 -0
- package/personas-teacher/specialized/study-abroad-advisor.md +282 -0
- package/personas-teacher/specialized/supply-chain-strategist.md +582 -0
- package/personas-teacher/specialized/zk-steward.md +211 -0
- package/personas-teacher/strategy/EXECUTIVE-BRIEF.md +95 -0
- package/personas-teacher/strategy/QUICKSTART.md +194 -0
- package/personas-teacher/strategy/coordination/agent-activation-prompts.md +401 -0
- package/personas-teacher/strategy/coordination/handoff-templates.md +357 -0
- package/personas-teacher/strategy/nexus-strategy.md +1110 -0
- package/personas-teacher/strategy/playbooks/phase-0-discovery.md +178 -0
- package/personas-teacher/strategy/playbooks/phase-1-strategy.md +238 -0
- package/personas-teacher/strategy/playbooks/phase-2-foundation.md +278 -0
- package/personas-teacher/strategy/playbooks/phase-3-build.md +286 -0
- package/personas-teacher/strategy/playbooks/phase-4-hardening.md +332 -0
- package/personas-teacher/strategy/playbooks/phase-5-launch.md +277 -0
- package/personas-teacher/strategy/playbooks/phase-6-operate.md +318 -0
- package/personas-teacher/strategy/runbooks/scenario-enterprise-feature.md +157 -0
- package/personas-teacher/strategy/runbooks/scenario-incident-response.md +217 -0
- package/personas-teacher/strategy/runbooks/scenario-marketing-campaign.md +187 -0
- package/personas-teacher/strategy/runbooks/scenario-startup-mvp.md +154 -0
- package/personas-teacher/support/support-analytics-reporter.md +365 -0
- package/personas-teacher/support/support-executive-summary-generator.md +212 -0
- package/personas-teacher/support/support-finance-tracker.md +442 -0
- package/personas-teacher/support/support-infrastructure-maintainer.md +618 -0
- package/personas-teacher/support/support-legal-compliance-checker.md +588 -0
- package/personas-teacher/support/support-support-responder.md +585 -0
- package/personas-teacher/testing/testing-accessibility-auditor.md +316 -0
- package/personas-teacher/testing/testing-api-tester.md +306 -0
- package/personas-teacher/testing/testing-evidence-collector.md +210 -0
- package/personas-teacher/testing/testing-performance-benchmarker.md +268 -0
- package/personas-teacher/testing/testing-reality-checker.md +236 -0
- package/personas-teacher/testing/testing-test-results-analyzer.md +305 -0
- package/personas-teacher/testing/testing-tool-evaluator.md +394 -0
- package/personas-teacher/testing/testing-workflow-optimizer.md +450 -0
- package/personas-teacher/tools.json +19 -0
- package/skill/add/SKILL.md +20 -14
- package/skill/add/advisor.md +33 -34
- package/skill/add/confidence.md +1 -1
- package/skill/add/deltas.md +12 -13
- package/skill/add/design.md +52 -49
- package/skill/add/fold.md +18 -16
- package/skill/add/intake.md +4 -3
- package/skill/add/loop.md +3 -4
- package/skill/add/phases/0-ground.md +32 -34
- package/skill/add/phases/0-setup.md +22 -21
- package/skill/add/phases/1-specify.md +8 -6
- package/skill/add/phases/3-contract.md +1 -1
- package/skill/add/phases/4-tests.md +9 -10
- package/skill/add/phases/5-build.md +17 -13
- package/skill/add/phases/6-verify.md +9 -4
- package/skill/add/phases/7-observe.md +2 -1
- package/skill/add/report-template.md +62 -18
- package/skill/add/run.md +5 -3
- package/skill/add/scope.md +2 -2
- package/skill/add/sensitivity.md +51 -0
- package/skill/add/streams.md +31 -23
- package/tooling/add.py +906 -28
- package/tooling/add_engine/autonomy.py +36 -1
- package/tooling/add_engine/constants.py +56 -3
- package/tooling/add_engine/guidelines.py +22 -20
- package/tooling/add_engine/predicates.py +56 -3
- package/tooling/add_engine/search.py +165 -0
- package/tooling/templates/GLOSSARY.md.tmpl +16 -0
- package/tooling/templates/MILESTONE.md.tmpl +9 -0
- package/tooling/templates/PROMPT.persona.md.tmpl +44 -0
- package/tooling/templates/TASK.fast.md.tmpl +1 -0
- package/tooling/templates/TASK.md.tmpl +39 -6
- package/tooling/templates/gitignore.tmpl +12 -0
- package/tooling/templates/personas/_template.md.tmpl +60 -0
package/bin/cli.js
CHANGED
|
@@ -40,7 +40,7 @@ function parseArgs(argv) {
|
|
|
40
40
|
// hint only echoes flags the user actually chose (shortest true command).
|
|
41
41
|
const args = { _: [], force: false, check: false, noSkill: false, stage: null, name: null,
|
|
42
42
|
yes: false, nonInteractive: false, global: false, globalData: false,
|
|
43
|
-
fromGlobalData: false, ruleFile: false };
|
|
43
|
+
fromGlobalData: false, ruleFile: false, lockTimeout: null };
|
|
44
44
|
for (let i = 0; i < argv.length; i++) {
|
|
45
45
|
const a = argv[i];
|
|
46
46
|
if (a === "--force") args.force = true;
|
|
@@ -72,6 +72,14 @@ function parseArgs(argv) {
|
|
|
72
72
|
if (v == null || v.startsWith("--")) fail(a + " requires a value");
|
|
73
73
|
if (a === "--stage") args.stage = v; else args.name = v;
|
|
74
74
|
}
|
|
75
|
+
// --lock-timeout <seconds>: (--global only) opt into a bounded wait for a LIVE contended
|
|
76
|
+
// home lock before failing "update_in_progress" (default null = today's immediate fail-fast;
|
|
77
|
+
// a STALE lock always self-heals regardless). SAME "requires a value" idiom as --stage/--name.
|
|
78
|
+
else if (a === "--lock-timeout") {
|
|
79
|
+
const v = argv[++i];
|
|
80
|
+
if (v == null || v.startsWith("--")) fail(a + " requires a value");
|
|
81
|
+
args.lockTimeout = Number(v);
|
|
82
|
+
}
|
|
75
83
|
else if (a.startsWith("--")) warn("ignoring unknown flag " + a);
|
|
76
84
|
else args._.push(a);
|
|
77
85
|
}
|
|
@@ -585,11 +593,21 @@ function seedSoulMd(target) {
|
|
|
585
593
|
}
|
|
586
594
|
}
|
|
587
595
|
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
596
|
+
// kept OUTSIDE add_engine/constants.py / gitignore.tmpl deliberately: the engine's own
|
|
597
|
+
// _GITIGNORE_BODY constant must never contain "personas-teacher" (test_engine_unchanged_
|
|
598
|
+
// and_handsoff — the engine stays hands-off of the teacher vendor tree). The INSTALLER
|
|
599
|
+
// already names that tree explicitly (MANAGED/OPTIONAL), so it is free to seed this one
|
|
600
|
+
// extra ignore line itself. BARE (not repo-root style): .add/.gitignore lives INSIDE
|
|
601
|
+
// .add/, so git resolves its patterns relative to .add/ itself. Twin of
|
|
602
|
+
// _installer.py:_INSTALLER_MANAGED_IGNORE_EXTRA.
|
|
603
|
+
const INSTALLER_MANAGED_IGNORE_EXTRA = ["personas-teacher/"];
|
|
604
|
+
|
|
605
|
+
// Ensure .add/.gitignore lists the engine's transient artifacts + managed vendor trees.
|
|
606
|
+
// Seed it from the bundled tooling/templates/gitignore.tmpl (plus
|
|
607
|
+
// INSTALLER_MANAGED_IGNORE_EXTRA) if absent; else APPEND-IF-ABSENT each pattern line that
|
|
608
|
+
// combined body carries that the file lacks — additive only, never reorders/removes user
|
|
609
|
+
// lines, idempotent; comment/blank lines are not appended to an existing file. Fail-soft.
|
|
610
|
+
// Twin of _installer.py:_seed_gitignore.
|
|
593
611
|
function seedGitignore(target) {
|
|
594
612
|
const source = path.join(PKG_ROOT, "tooling", "templates", "gitignore.tmpl");
|
|
595
613
|
if (!fs.existsSync(source)) {
|
|
@@ -598,7 +616,9 @@ function seedGitignore(target) {
|
|
|
598
616
|
}
|
|
599
617
|
const dest = path.join(target, ".add", ".gitignore");
|
|
600
618
|
try {
|
|
601
|
-
|
|
619
|
+
let body = fs.readFileSync(source, "utf8");
|
|
620
|
+
if (!body.endsWith("\n")) body += "\n";
|
|
621
|
+
body += INSTALLER_MANAGED_IGNORE_EXTRA.join("\n") + "\n";
|
|
602
622
|
if (!fs.existsSync(dest)) {
|
|
603
623
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
604
624
|
fs.writeFileSync(dest, body); // seed-if-missing
|
|
@@ -707,6 +727,14 @@ async function cmdInit(args) {
|
|
|
707
727
|
"from; run `init --global-data` on a source checkout first");
|
|
708
728
|
}
|
|
709
729
|
}
|
|
730
|
+
// Project-scope lock (project-scope-install-lock): keyed on chosenTarget's FINAL value (after
|
|
731
|
+
// any interactive redirect above) — acquired BEFORE the as_global sub-block, held through the
|
|
732
|
+
// function's end. Independent of, and acquired BEFORE, the home-scoped acquireUpdateLock that
|
|
733
|
+
// installGlobal() below nests inside (M11 — never the reverse nesting).
|
|
734
|
+
const addDir = path.join(chosenTarget, ".add");
|
|
735
|
+
acquireProjectLock(addDir); // registers its own process.on("exit", release) — no explicit
|
|
736
|
+
// release()/finally needed at the call site (mirrors
|
|
737
|
+
// acquireUpdateLock's own usage at cmdUpdateGlobal)
|
|
710
738
|
// OPT-IN global home, BEFORE the per-project drop (fail-closed if the home is unwritable
|
|
711
739
|
// or its registry is corrupt — the package + the self-contained default stay usable).
|
|
712
740
|
if (args.global) installGlobal(args, chosenTarget);
|
|
@@ -723,11 +751,50 @@ async function cmdInit(args) {
|
|
|
723
751
|
// tasks, or archive (user data). Pure file-copy (npm <-> pip parity with _installer.py).
|
|
724
752
|
const MANAGED = [
|
|
725
753
|
["skill/add", [".claude", "skills", "add"], false],
|
|
754
|
+
["agents", [".claude", "agents"], false],
|
|
726
755
|
["tooling", [".add", "tooling"], true],
|
|
727
756
|
["docs", [".add", "docs"], false],
|
|
757
|
+
["personas-teacher", [".add", "personas-teacher"], false],
|
|
728
758
|
];
|
|
759
|
+
// Optional managed trees: an ENHANCEMENT the persona phase reads, not core runtime. The real
|
|
760
|
+
// package always ships these (guarded by test_packaging); a malformed/older package missing one
|
|
761
|
+
// must NOT abort the install — the core lands and the optional tree is soft-skipped. Twin of
|
|
762
|
+
// _installer.py:OPTIONAL. Design-for-failure. `agents` joins here (roster-install-drift): the
|
|
763
|
+
// phase-agent roster is a spawn-acceleration enhancement, not core runtime.
|
|
764
|
+
const OPTIONAL = new Set(["personas-teacher", "agents"]);
|
|
729
765
|
const STAMP_FILE = ".add-version";
|
|
730
766
|
const LOCK_FILE = ".update.lock"; // the `update --global` home lock (never user-data)
|
|
767
|
+
const LOCK_STALE_DEFAULT = 600; // seconds (10 min); ADD_LOCK_STALE_SECONDS env-overridable
|
|
768
|
+
const LOCK_POLL_INTERVAL_MS = 50; // ms between polls while waiting out a --lock-timeout
|
|
769
|
+
const LOCK_TICKET_STALE_SECONDS = 5; // a leaked per-generation reclaim ticket (its own holder
|
|
770
|
+
// crashed between winning it and its own best-effort
|
|
771
|
+
// cleanup) self-heals after this long — deliberately far
|
|
772
|
+
// shorter than LOCK_STALE_DEFAULT's own 600s: a ticket's
|
|
773
|
+
// own critical section is a small, fixed handful of
|
|
774
|
+
// syscalls (close/stat/unlink), microseconds under normal
|
|
775
|
+
// operation, so a multi-second margin is generous, not
|
|
776
|
+
// tight (global-lock-followups' own leaked-ticket-livelock
|
|
777
|
+
// fix — independent of, not shared with, acquireProjectLock's
|
|
778
|
+
// own PROJECT_LOCK_TICKET_STALE_SECONDS below).
|
|
779
|
+
const PROJECT_LOCK_FILE = ".install.lock"; // the project-scope init()/update() lock (never user-data)
|
|
780
|
+
const PROJECT_LOCK_STALE_DEFAULT = 120; // seconds (2 min); ADD_PROJECT_LOCK_STALE_SECONDS env-overridable —
|
|
781
|
+
// deliberately SHORTER than LOCK_STALE_DEFAULT's own 600s (see
|
|
782
|
+
// _installer.py's _PROJECT_LOCK_STALE_DEFAULT for the reasoning)
|
|
783
|
+
const PROJECT_LOCK_TICKET_STALE_SECONDS = 5; // a leaked per-generation reclaim ticket self-heals
|
|
784
|
+
// after this long — independent of, but numerically
|
|
785
|
+
// identical to, acquireUpdateLock's own
|
|
786
|
+
// LOCK_TICKET_STALE_SECONDS: a ticket's own critical
|
|
787
|
+
// section is the same small, fixed handful of
|
|
788
|
+
// syscalls regardless of which lock it guards
|
|
789
|
+
// (project-scope-install-lock's own
|
|
790
|
+
// leaked-ticket-wedge fix)
|
|
791
|
+
|
|
792
|
+
// A synchronous sleep via Atomics.wait on a throwaway SharedArrayBuffer — builtin, no new
|
|
793
|
+
// dependency; Node's MAIN thread (unlike a browser's) is allowed to block on Atomics.wait.
|
|
794
|
+
function sleepSync(ms) {
|
|
795
|
+
const ia = new Int32Array(new SharedArrayBuffer(4));
|
|
796
|
+
Atomics.wait(ia, 0, 0, ms);
|
|
797
|
+
}
|
|
731
798
|
|
|
732
799
|
function pkgVersion() {
|
|
733
800
|
try { return require(path.join(PKG_ROOT, "package.json")).version; }
|
|
@@ -764,29 +831,93 @@ function treeFiles(root) {
|
|
|
764
831
|
return out;
|
|
765
832
|
}
|
|
766
833
|
|
|
834
|
+
// Crash-safe stage-then-swap: project-scope-atomic-reconcile (TASK.md v1). Replaces the
|
|
835
|
+
// old wipe-then-copy (rmSync(dest) then cpSync onto dest directly) with a stage-into-a-
|
|
836
|
+
// sibling + two-rename commit, so dest is NEVER observed half-old/half-new (a random
|
|
837
|
+
// partial mix) — the achievable guarantee is "never observed half-composed", not "never
|
|
838
|
+
// observed absent for an instant" (a single-syscall atomic replace of an EXISTING
|
|
839
|
+
// non-empty directory is not portable; the sub-instant window between the two commit
|
|
840
|
+
// renames is closed by the NEXT call's own self-heal, not this one).
|
|
841
|
+
//
|
|
767
842
|
// Returns a file-level roll-up of the heal: { restored, refreshed } where `restored` = a
|
|
768
|
-
// file in the FINAL tree whose relative path was ABSENT before the
|
|
843
|
+
// file in the FINAL tree whose relative path was ABSENT before the call (fresh or
|
|
769
844
|
// partially-gutted trees heal these), `refreshed` = a final file that was PRESENT before
|
|
770
845
|
// (re-materialized). Orphans (present before, gone after) are swept, counted as neither.
|
|
771
846
|
// Pure observation — copy semantics unchanged. Mirror of _installer.py:_clean_replace.
|
|
772
847
|
function cleanReplaceTree(src, dest, stripTests) {
|
|
773
848
|
if (!fs.existsSync(src)) fail("missing packaged source: " + src);
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
849
|
+
const destParent = path.dirname(dest);
|
|
850
|
+
const destName = path.basename(dest);
|
|
851
|
+
fs.mkdirSync(destParent, { recursive: true });
|
|
852
|
+
|
|
853
|
+
// -- self-heal -- (before this call's own work): recover/discard whatever a PRIOR,
|
|
854
|
+
// interrupted call left behind. A stale backup found while dest is absent is the last
|
|
855
|
+
// known-good tree — restore it first (a cheap rename), minimizing how long dest stays
|
|
856
|
+
// broken; any other stale sibling (a stage, or a backup found while dest is present) is
|
|
857
|
+
// discarded outright — its content is never merged or reused.
|
|
858
|
+
const siblings = fs.readdirSync(destParent);
|
|
859
|
+
const tmpStales = siblings.filter((n) => n.startsWith(destName + ".add-tmp-"));
|
|
860
|
+
const bakStales = siblings.filter((n) => n.startsWith(destName + ".add-bak-"))
|
|
861
|
+
.map((n) => path.join(destParent, n))
|
|
862
|
+
.sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs);
|
|
863
|
+
let remainingBaks = bakStales;
|
|
864
|
+
if (!fs.existsSync(dest) && bakStales.length > 0) {
|
|
865
|
+
const winner = bakStales[bakStales.length - 1]; // most-recently-modified is authoritative
|
|
866
|
+
fs.renameSync(winner, dest);
|
|
867
|
+
remainingBaks = bakStales.slice(0, -1);
|
|
868
|
+
}
|
|
869
|
+
for (const stale of tmpStales.map((n) => path.join(destParent, n)).concat(remainingBaks)) {
|
|
870
|
+
fs.rmSync(stale, { recursive: true, force: true });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const before = treeFiles(dest); // snapshot BEFORE this call's own work
|
|
874
|
+
|
|
875
|
+
// -- stage -- : copy src into a fresh, uniquely-named sibling of dest, in dest's own
|
|
876
|
+
// parent (same filesystem, so the commit renames below are a genuine atomic move). dest
|
|
877
|
+
// itself is never opened for writing or deletion during this step.
|
|
878
|
+
const staged = fs.mkdtempSync(path.join(destParent, destName + ".add-tmp-"));
|
|
879
|
+
try {
|
|
880
|
+
fs.cpSync(src, staged, { recursive: true });
|
|
881
|
+
if (stripTests) {
|
|
882
|
+
fs.rmSync(path.join(staged, "__pycache__"), { recursive: true, force: true });
|
|
883
|
+
for (const entry of fs.readdirSync(staged)) {
|
|
884
|
+
if (/^test_.*\.py$/.test(entry)) fs.rmSync(path.join(staged, entry), { force: true });
|
|
885
|
+
}
|
|
782
886
|
}
|
|
887
|
+
} catch (e) {
|
|
888
|
+
fs.rmSync(staged, { recursive: true, force: true }); // dest untouched — it was never opened
|
|
889
|
+
throw e;
|
|
783
890
|
}
|
|
891
|
+
|
|
892
|
+
// -- commit -- : two same-parent renames, NEITHER targets an already-existing name.
|
|
893
|
+
const token = path.basename(staged).slice((destName + ".add-tmp-").length);
|
|
894
|
+
let bak = null;
|
|
895
|
+
if (fs.existsSync(dest)) {
|
|
896
|
+
bak = path.join(destParent, destName + ".add-bak-" + token);
|
|
897
|
+
try {
|
|
898
|
+
fs.renameSync(dest, bak); // (a) vacate dest, aside
|
|
899
|
+
} catch (e) {
|
|
900
|
+
fs.rmSync(staged, { recursive: true, force: true }); // dest untouched — the rename never happened
|
|
901
|
+
throw e;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
try {
|
|
905
|
+
fs.renameSync(staged, dest); // (b) land the new generation
|
|
906
|
+
} catch (e) {
|
|
907
|
+
if (bak !== null) fs.renameSync(bak, dest); // roll back: restore the original
|
|
908
|
+
fs.rmSync(staged, { recursive: true, force: true });
|
|
909
|
+
throw e;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// -- sweep -- : remove the backup sibling — ONLY after (b) has landed the new dest.
|
|
913
|
+
if (bak !== null) fs.rmSync(bak, { recursive: true, force: true });
|
|
914
|
+
|
|
784
915
|
let restored = 0, refreshed = 0;
|
|
785
916
|
for (const f of treeFiles(dest)) (before.has(f) ? refreshed++ : restored++);
|
|
786
917
|
return { restored: restored, refreshed: refreshed };
|
|
787
918
|
}
|
|
788
919
|
|
|
789
|
-
const TREE_LABEL = { "skill/add": "skill", "tooling": "tooling", "docs": "docs" };
|
|
920
|
+
const TREE_LABEL = { "skill/add": "skill", "agents": "agents", "tooling": "tooling", "docs": "docs", "personas-teacher": "personas" };
|
|
790
921
|
|
|
791
922
|
// Per managed tree: "missing" (dest absent OR empty) or "present".
|
|
792
923
|
function managedStatus(target) {
|
|
@@ -805,7 +936,9 @@ function managedStatus(target) {
|
|
|
805
936
|
// a corrupt package leaves the target untouched).
|
|
806
937
|
function reconcile(args, target, srcRoot) {
|
|
807
938
|
srcRoot = srcRoot || PKG_ROOT; // default: the package; the global home feeds propagation
|
|
808
|
-
const trees = MANAGED
|
|
939
|
+
const trees = MANAGED
|
|
940
|
+
.filter(([sub]) => !(sub === "skill/add" && args.noSkill))
|
|
941
|
+
.filter(([sub]) => !(OPTIONAL.has(sub) && !fs.existsSync(path.join(srcRoot, sub)))); // soft-skip absent optional
|
|
809
942
|
for (const [sub] of trees) {
|
|
810
943
|
if (!fs.existsSync(path.join(srcRoot, sub))) {
|
|
811
944
|
fail("missing packaged source: " + path.join(srcRoot, sub));
|
|
@@ -876,18 +1009,20 @@ const GLOBAL_TREES = [
|
|
|
876
1009
|
["skill/add", ["skill", "add"], false],
|
|
877
1010
|
["tooling", ["tooling"], true],
|
|
878
1011
|
["docs", ["docs"], false],
|
|
1012
|
+
["personas-teacher", ["personas-teacher"], false],
|
|
879
1013
|
];
|
|
880
1014
|
|
|
881
1015
|
// Clean-replace the bundled managed layer INTO <home> (canonical mirror), then DEPLOY the
|
|
882
1016
|
// skill to ~/.claude/skills/add. Throws if a dir can't be written (caller -> home_unwritable).
|
|
883
1017
|
// Prechecks ALL sources first (design-for-failure: a corrupt package leaves the home as-is).
|
|
884
1018
|
function reconcileGlobal(home, claudeDir, noSkill) {
|
|
885
|
-
|
|
1019
|
+
const trees = GLOBAL_TREES.filter(([sub]) => !(OPTIONAL.has(sub) && !fs.existsSync(path.join(PKG_ROOT, sub))));
|
|
1020
|
+
for (const [sub] of trees) {
|
|
886
1021
|
if (!fs.existsSync(path.join(PKG_ROOT, sub))) {
|
|
887
1022
|
fail("missing packaged source: " + path.join(PKG_ROOT, sub));
|
|
888
1023
|
}
|
|
889
1024
|
}
|
|
890
|
-
for (const [sub, destParts, stripTests] of
|
|
1025
|
+
for (const [sub, destParts, stripTests] of trees) {
|
|
891
1026
|
cleanReplaceTree(path.join(PKG_ROOT, sub), path.join(home, ...destParts), stripTests);
|
|
892
1027
|
}
|
|
893
1028
|
if (!noSkill) cleanReplaceTree(path.join(home, "skill", "add"), claudeDir, false);
|
|
@@ -896,7 +1031,7 @@ function reconcileGlobal(home, claudeDir, noSkill) {
|
|
|
896
1031
|
// --- global DATA: an OPT-IN per-project user-data snapshot under <home>/data/<key> ----------
|
|
897
1032
|
// Strictly additive; copies ONLY user-data (managed trees + transient excluded), clean-replaced,
|
|
898
1033
|
// one-way (project->home). Mirror of _installer.py (identical key + include/exclude rule).
|
|
899
|
-
const DATA_EXCLUDE = ["tooling", "docs", ".update-cache", STAMP_FILE, LOCK_FILE]; // managed trees + meta +
|
|
1034
|
+
const DATA_EXCLUDE = ["tooling", "docs", ".update-cache", STAMP_FILE, LOCK_FILE, PROJECT_LOCK_FILE]; // managed trees + meta + both locks
|
|
900
1035
|
|
|
901
1036
|
// data_key twin: <sanitized-basename>-<sha1(abspath_utf8)[:12]>. Pure · total · separator-free.
|
|
902
1037
|
function dataKey(projectAbspath) {
|
|
@@ -906,28 +1041,102 @@ function dataKey(projectAbspath) {
|
|
|
906
1041
|
return base + "-" + digest;
|
|
907
1042
|
}
|
|
908
1043
|
|
|
909
|
-
// A top-level .add/ entry is user-data unless it is a managed tree
|
|
1044
|
+
// A top-level .add/ entry is user-data unless it is a managed tree, a transient artifact, a
|
|
1045
|
+
// scratch-staging sibling left by an interrupted persist/restore/clean-replace call (the shared
|
|
1046
|
+
// .add-tmp-/.add-bak- infix convention — crash-safe-persist-restore v1 §3 M11), or a
|
|
1047
|
+
// per-generation reclaim-ticket sibling a lock's own stale-reclaim mechanism may transiently (or,
|
|
1048
|
+
// if leaked by a crash, semi-persistently) leave next to it (the .reclaim-<inode> infix
|
|
1049
|
+
// convention — project-scope-install-lock / global-lock-followups' leaked-ticket self-heal fix).
|
|
910
1050
|
function isUserData(name) {
|
|
911
1051
|
if (DATA_EXCLUDE.includes(name)) return false;
|
|
912
1052
|
if (name.startsWith("scope-snapshot")) return false;
|
|
913
1053
|
if (name.includes("pre-archive-bak")) return false;
|
|
914
1054
|
if (name.endsWith(".bak.json")) return false;
|
|
1055
|
+
if (name.includes(".add-tmp-") || name.includes(".add-bak-")) return false;
|
|
1056
|
+
if (name.includes(".reclaim-")) return false;
|
|
915
1057
|
return true;
|
|
916
1058
|
}
|
|
917
1059
|
|
|
918
|
-
//
|
|
919
|
-
//
|
|
1060
|
+
// Best-effort removal of a scratch sibling (staging dir/file, or a whole-tree backup dir);
|
|
1061
|
+
// tolerates it already being gone. A sweep failure here is hygiene, not a correctness gap —
|
|
1062
|
+
// self-heal simply retries it on the next call touching this target.
|
|
1063
|
+
function sweepScratch(p) {
|
|
1064
|
+
try { fs.rmSync(p, { recursive: true, force: true }); } catch (_e) { /* best-effort */ }
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Crash-safe clean-replace of a project's USER-DATA into <home>/data/<key>: self-heal any
|
|
1068
|
+
// scratch sibling left by an earlier interrupted call, then a WHOLE-TREE stage-then-commit
|
|
1069
|
+
// (mirrors cleanReplaceTree's own pattern) — dest is never opened for writing or deletion until
|
|
1070
|
+
// its replacement has FULLY landed. true=persisted, false=skipped (no .add or no user-data — an
|
|
1071
|
+
// honest skip, dest — including any EXISTING stale snapshot — left completely untouched).
|
|
1072
|
+
// Throws if the data dir can't be written.
|
|
920
1073
|
function persistData(home, projectAbspath) {
|
|
1074
|
+
const key = dataKey(projectAbspath);
|
|
1075
|
+
const dataRoot = path.join(home, "data");
|
|
1076
|
+
const dest = path.join(dataRoot, key);
|
|
1077
|
+
|
|
1078
|
+
// step 0 -- self-heal: a stale backup found while dest is currently ABSENT means a PRIOR call
|
|
1079
|
+
// crashed between its two commit renames -- restore it first (the newest wins a >1 tie-break,
|
|
1080
|
+
// a defensive case, not an expected path). Then sweep every remaining scratch sibling for this
|
|
1081
|
+
// key unconditionally (never merged/reused).
|
|
1082
|
+
if (fs.existsSync(dataRoot)) {
|
|
1083
|
+
const siblings = fs.readdirSync(dataRoot);
|
|
1084
|
+
const tmpStale = siblings.filter((n) => n.startsWith(key + ".add-tmp-"));
|
|
1085
|
+
let bakStale = siblings.filter((n) => n.startsWith(key + ".add-bak-"));
|
|
1086
|
+
if (!fs.existsSync(dest) && bakStale.length > 0) {
|
|
1087
|
+
let newest = bakStale[0];
|
|
1088
|
+
let newestMtime = fs.statSync(path.join(dataRoot, newest)).mtimeMs;
|
|
1089
|
+
for (const n of bakStale.slice(1)) {
|
|
1090
|
+
const t = fs.statSync(path.join(dataRoot, n)).mtimeMs;
|
|
1091
|
+
if (t > newestMtime) { newest = n; newestMtime = t; }
|
|
1092
|
+
}
|
|
1093
|
+
fs.renameSync(path.join(dataRoot, newest), dest);
|
|
1094
|
+
bakStale = bakStale.filter((n) => n !== newest);
|
|
1095
|
+
}
|
|
1096
|
+
for (const n of bakStale) sweepScratch(path.join(dataRoot, n));
|
|
1097
|
+
for (const n of tmpStale) sweepScratch(path.join(dataRoot, n));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
921
1100
|
const addDir = path.join(projectAbspath, ".add");
|
|
922
1101
|
if (!fs.existsSync(addDir)) return false;
|
|
923
1102
|
const entries = fs.readdirSync(addDir).filter(isUserData);
|
|
924
|
-
if (entries.length === 0) return false;
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1103
|
+
if (entries.length === 0) return false; // UNCHANGED: an existing stale snapshot is left as-is
|
|
1104
|
+
|
|
1105
|
+
// step 1 -- stage: copy every filtered entry into a fresh, uniquely-named sibling of dest, IN
|
|
1106
|
+
// home/data/. dest itself is never opened for writing during this step.
|
|
1107
|
+
fs.mkdirSync(dataRoot, { recursive: true });
|
|
1108
|
+
const staged = fs.mkdtempSync(path.join(dataRoot, key + ".add-tmp-"));
|
|
1109
|
+
try {
|
|
1110
|
+
for (const e of entries) {
|
|
1111
|
+
fs.cpSync(path.join(addDir, e), path.join(staged, e), { recursive: true });
|
|
1112
|
+
}
|
|
1113
|
+
} catch (e) {
|
|
1114
|
+
sweepScratch(staged);
|
|
1115
|
+
throw e;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// step 2 -- commit: two same-parent renames, neither ever targets an already-existing name.
|
|
1119
|
+
const backup = path.join(dataRoot, key + ".add-bak-" + crypto.randomBytes(6).toString("hex"));
|
|
1120
|
+
let asideLanded = false;
|
|
1121
|
+
if (fs.existsSync(dest)) {
|
|
1122
|
+
try {
|
|
1123
|
+
fs.renameSync(dest, backup);
|
|
1124
|
+
asideLanded = true;
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
sweepScratch(staged);
|
|
1127
|
+
throw e;
|
|
1128
|
+
}
|
|
930
1129
|
}
|
|
1130
|
+
try {
|
|
1131
|
+
fs.renameSync(staged, dest);
|
|
1132
|
+
} catch (e) {
|
|
1133
|
+
if (asideLanded) fs.renameSync(backup, dest); // roll back: dest ends where it started
|
|
1134
|
+
sweepScratch(staged);
|
|
1135
|
+
throw e;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// step 3 -- sweep: the old backup is removed only now that the new dest has landed.
|
|
1139
|
+
if (asideLanded) sweepScratch(backup);
|
|
931
1140
|
return true;
|
|
932
1141
|
}
|
|
933
1142
|
|
|
@@ -952,27 +1161,71 @@ function isSymlink(p) {
|
|
|
952
1161
|
}
|
|
953
1162
|
|
|
954
1163
|
// Restore USER-DATA from <home>/data/<key> into <project>/.add — the NON-DESTRUCTIVE inverse of
|
|
955
|
-
// persistData
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
1164
|
+
// persistData, crash-safe via a PER-ENTRY stage-then-commit (.add/ is a SHARED directory most of
|
|
1165
|
+
// which this function must leave alone, so — unlike persistData's self-owned whole-tree dest —
|
|
1166
|
+
// only ONE entry stages/commits at a time). FILL-GAPS by default (write only ABSENT entries);
|
|
1167
|
+
// force overwrites a present entry, writing a <name>.bak sidecar of the original FIRST (the SAME
|
|
1168
|
+
// permanent, already-contracted sidecar name as before this task — never the new transient
|
|
1169
|
+
// staging marker). Copies only isUserData entries; DEREFERENCES symlinks to content (cpSync
|
|
1170
|
+
// dereference). true if >=1 restored, false if nothing to restore. Throws on an unwritable dest
|
|
1171
|
+
// -> restore_failed. Mirror of _installer.py:_restore_data.
|
|
959
1172
|
function restoreData(home, projectAbspath, force) {
|
|
1173
|
+
const addDir = path.join(projectAbspath, ".add");
|
|
960
1174
|
const src = path.join(home, "data", dataKey(projectAbspath));
|
|
1175
|
+
|
|
1176
|
+
// step 0 -- self-heal: sweep any stale per-entry staging sibling left by an earlier
|
|
1177
|
+
// INTERRUPTED call, unconditionally (never merged/reused/completed) -- the untouched snapshot
|
|
1178
|
+
// at src lets THIS call's own ordinary fill-gaps-or-force logic re-derive the correct end
|
|
1179
|
+
// state; no backup-recovery step is needed here (unlike persistData's step 0).
|
|
1180
|
+
if (fs.existsSync(addDir)) {
|
|
1181
|
+
for (const n of fs.readdirSync(addDir)) {
|
|
1182
|
+
if (n.includes(".add-tmp-")) sweepScratch(path.join(addDir, n));
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
961
1186
|
if (!fs.existsSync(src)) return false;
|
|
962
1187
|
const entries = fs.readdirSync(src).filter(isUserData).sort();
|
|
963
1188
|
if (entries.length === 0) return false;
|
|
964
|
-
const addDir = path.join(projectAbspath, ".add");
|
|
965
1189
|
fs.mkdirSync(addDir, { recursive: true });
|
|
966
1190
|
let restored = false;
|
|
967
1191
|
for (const e of entries) {
|
|
968
1192
|
const dest = path.join(addDir, e);
|
|
969
|
-
|
|
970
|
-
|
|
1193
|
+
const existed = fs.existsSync(dest) || isSymlink(dest);
|
|
1194
|
+
if (existed && !force) continue; // fill-gaps: never clobber; no staging begins
|
|
1195
|
+
|
|
1196
|
+
// step 1b -- stage: copy this ONE entry into a fresh, uniquely-named sibling of dest, IN
|
|
1197
|
+
// .add/ -- dest's own name is not opened for writing during this step. A reserved unique
|
|
1198
|
+
// name is claimed via mkdtempSync, then either kept as a dir (cpSync merges into the empty
|
|
1199
|
+
// dir) or freed via rmdirSync for a plain-file copy.
|
|
1200
|
+
const staged = fs.mkdtempSync(path.join(addDir, e + ".add-tmp-"));
|
|
1201
|
+
try {
|
|
1202
|
+
const srcEntry = path.join(src, e);
|
|
1203
|
+
if (fs.statSync(srcEntry).isDirectory()) {
|
|
1204
|
+
fs.cpSync(srcEntry, staged, { recursive: true, dereference: true });
|
|
1205
|
+
} else {
|
|
1206
|
+
fs.rmdirSync(staged);
|
|
1207
|
+
fs.cpSync(srcEntry, staged, { dereference: true }); // deref a symlink source
|
|
1208
|
+
}
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
sweepScratch(staged);
|
|
1211
|
+
throw err;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// step 1c -- commit:
|
|
1215
|
+
if (existed) {
|
|
971
1216
|
const bak = dest + ".bak";
|
|
972
|
-
if (fs.existsSync(bak) || isSymlink(bak))
|
|
973
|
-
fs.renameSync(dest, bak);
|
|
1217
|
+
if (fs.existsSync(bak) || isSymlink(bak)) sweepScratch(bak); // a stale .bak: replace, don't merge
|
|
1218
|
+
fs.renameSync(dest, bak); // back up the original BEFORE replacing
|
|
1219
|
+
try {
|
|
1220
|
+
fs.renameSync(staged, dest);
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
fs.renameSync(bak, dest); // roll back: this entry ends where it started
|
|
1223
|
+
sweepScratch(staged);
|
|
1224
|
+
throw err;
|
|
1225
|
+
}
|
|
1226
|
+
} else {
|
|
1227
|
+
fs.renameSync(staged, dest); // fill-gaps: single rename, no backup needed
|
|
974
1228
|
}
|
|
975
|
-
fs.cpSync(path.join(src, e), dest, { recursive: true, dereference: true }); // deref symlinks
|
|
976
1229
|
restored = true;
|
|
977
1230
|
}
|
|
978
1231
|
return restored;
|
|
@@ -1038,9 +1291,14 @@ function cmdPruneData(args) {
|
|
|
1038
1291
|
|
|
1039
1292
|
// init --global: install the managed layer ONCE to the shared home + register this project,
|
|
1040
1293
|
// fail-closed BEFORE the per-project drop. Returns the resolved target for the normal drop.
|
|
1294
|
+
// Serialized under the SAME home lock update --global uses (global-lock-followups M3) — a lock
|
|
1295
|
+
// failure aborts BEFORE any home/registry write and BEFORE the per-project drop (dropFiles,
|
|
1296
|
+
// called by cmdInit right after this returns), matching the all-or-nothing precedent every
|
|
1297
|
+
// other as_global-path failure already has.
|
|
1041
1298
|
function installGlobal(args, chosenTarget) {
|
|
1042
1299
|
const home = resolveGlobalHome(process.env);
|
|
1043
1300
|
const claudeDir = claudeSkillsDir(process.env);
|
|
1301
|
+
acquireUpdateLock(home, { timeout: args.lockTimeout }, process.env);
|
|
1044
1302
|
try { reconcileGlobal(home, claudeDir, args.noSkill); } // home_unwritable
|
|
1045
1303
|
catch (e) { fail("cannot write global home " + home + " — " + (e && e.message ? e.message : e)); }
|
|
1046
1304
|
writeStamp(home, pkgVersion(), "global");
|
|
@@ -1068,22 +1326,157 @@ function validRegistryPath(p) {
|
|
|
1068
1326
|
catch (_e) { return false; }
|
|
1069
1327
|
}
|
|
1070
1328
|
|
|
1071
|
-
// Serialize `update --global`
|
|
1072
|
-
//
|
|
1073
|
-
//
|
|
1074
|
-
//
|
|
1075
|
-
|
|
1329
|
+
// Serialize `update --global` (and, since global-lock-followups, `init --global`) with an
|
|
1330
|
+
// EXCLUSIVE lockfile (O_CREAT|O_EXCL via the "wx" flag) — the SAME mechanism as the pip twin
|
|
1331
|
+
// (_installer.py:_update_lock's os.open(O_EXCL)), so a pip-held lock blocks an npm run and
|
|
1332
|
+
// vice-versa.
|
|
1333
|
+
//
|
|
1334
|
+
// Held AND fresh (age <= ADD_LOCK_STALE_SECONDS, env-overridable, default 600s) -> today's
|
|
1335
|
+
// byte-identical behavior: `timeout` null/0 fails "update_in_progress" immediately; timeout=N>0
|
|
1336
|
+
// polls up to N seconds before failing.
|
|
1337
|
+
//
|
|
1338
|
+
// Held AND stale (age > threshold) -> self-heals: unlinks the stale lockfile and retries the
|
|
1339
|
+
// create. The "wx" create remains the SOLE mutual-exclusion primitive — staleness only ever
|
|
1340
|
+
// decides whether to retry, never bypasses exclusivity. A clock-skewed FUTURE mtime is a
|
|
1341
|
+
// negative age, so it is NEVER treated as stale.
|
|
1342
|
+
//
|
|
1343
|
+
// A successful acquire (fresh or reclaimed) stamps the file "<PID> <ISO ts>\n" — informational
|
|
1344
|
+
// ONLY, never read to decide staleness; a stamp-write failure is swallowed.
|
|
1345
|
+
//
|
|
1346
|
+
// KNOWN PROBLEM this shape works around: fail() calls process.exit(1) DIRECTLY (skips any
|
|
1347
|
+
// pending finally/loop state) — so this retry/self-heal loop uses `continue`/`break` for its
|
|
1348
|
+
// OWN internal control flow and calls fail() **at most once**, only AFTER the loop has
|
|
1349
|
+
// genuinely exhausted every retry/self-heal/wait attempt — never from inside the loop body.
|
|
1350
|
+
//
|
|
1351
|
+
// Released on process exit (normal completion OR fail()'s process.exit), so it never outlives
|
|
1352
|
+
// the run; a hard crash may leave a stale lock — the NEXT acquire self-heals it automatically.
|
|
1353
|
+
function acquireUpdateLock(home, { timeout = null } = {}, env = process.env) {
|
|
1076
1354
|
fs.mkdirSync(home, { recursive: true });
|
|
1077
1355
|
const lockPath = path.join(home, LOCK_FILE);
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1356
|
+
const staleAfterMs = Number(env.ADD_LOCK_STALE_SECONDS || LOCK_STALE_DEFAULT) * 1000;
|
|
1357
|
+
const deadline = timeout ? (Date.now() + timeout * 1000) : null; // null/0 = byte-identical fail-fast
|
|
1358
|
+
|
|
1359
|
+
let fd = null;
|
|
1360
|
+
let timedOut = false;
|
|
1361
|
+
while (fd === null) {
|
|
1362
|
+
try {
|
|
1363
|
+
fd = fs.openSync(lockPath, "wx");
|
|
1364
|
+
break;
|
|
1365
|
+
} catch (e) {
|
|
1366
|
+
if (!e || e.code !== "EEXIST") throw e;
|
|
1084
1367
|
}
|
|
1085
|
-
|
|
1368
|
+
let st;
|
|
1369
|
+
try { st = fs.statSync(lockPath); }
|
|
1370
|
+
catch (_e) { continue; } // vanished between the failed open and the stat — retry the create
|
|
1371
|
+
let reclaimed = false;
|
|
1372
|
+
if (Date.now() - st.mtimeMs > staleAfterMs) {
|
|
1373
|
+
// Identity-verified reclaim (fixes a TOCTOU race: an unconditional unlink-by-path let a
|
|
1374
|
+
// second racer delete a FIRST racer's already-recreated, live lock). A NAIVE "rename to a
|
|
1375
|
+
// quarantine name" does NOT fix this — a rename is JUST as identity-blind as an unlink: it
|
|
1376
|
+
// operates on whatever currently sits at `lockPath`, so a delayed racer's rename can just
|
|
1377
|
+
// as easily steal a WINNER's brand-new fresh file (empirically reproduced while building
|
|
1378
|
+
// this fix; that attempt made the race MEASURABLY WORSE, not better, by adding an extra
|
|
1379
|
+
// syscall to the vulnerable window). The actual fix: gate entry to the reclaim itself
|
|
1380
|
+
// behind a SEPARATE, per-GENERATION exclusive ticket, keyed to the CURRENT stale file's
|
|
1381
|
+
// own inode number (`st.ino` — stable for this file's lifetime, and a FRESH replacement
|
|
1382
|
+
// file always gets a NEW inode). Two racers that observe the SAME stale file compute the
|
|
1383
|
+
// IDENTICAL ticket name and race an exclusive create on IT — only one wins; every loser
|
|
1384
|
+
// backs off WITHOUT ever touching `lockPath` itself, so nobody can ever unlink/steal a
|
|
1385
|
+
// generation they didn't win the ticket for.
|
|
1386
|
+
const ticketPath = lockPath + ".reclaim-" + st.ino;
|
|
1387
|
+
let ticketFd = null;
|
|
1388
|
+
try {
|
|
1389
|
+
ticketFd = fs.openSync(ticketPath, "wx");
|
|
1390
|
+
} catch (_e) { /* LOST the ticket outright — checked below before giving up on it */ }
|
|
1391
|
+
if (ticketFd === null) {
|
|
1392
|
+
// LOST the per-generation reclaim ticket outright. Before treating this as "someone
|
|
1393
|
+
// else legitimately owns reclaiming THIS stale file right now," check whether THAT
|
|
1394
|
+
// ticket is itself orphaned — leaked by a process that crashed between winning it and
|
|
1395
|
+
// its own best-effort cleanup below. Left unchecked, a leaked ticket wedges this
|
|
1396
|
+
// generation's reclaim FOREVER: the ticket's name is deterministically keyed to
|
|
1397
|
+
// `lockPath`'s own (unchanging) inode, so every future contender recomputes the
|
|
1398
|
+
// IDENTICAL ticket path, loses the identical EEXIST race, and — pre-fix — `continue`d
|
|
1399
|
+
// straight back to the top of this loop without ever reaching the `deadline` check
|
|
1400
|
+
// below: an unbounded livelock no `--lock-timeout` could ever interrupt (found by a
|
|
1401
|
+
// fresh adversarial verify pass; independently reproduced here against the unmodified
|
|
1402
|
+
// code via both a direct call and a real `node cli.js` subprocess, both still spinning
|
|
1403
|
+
// well past their own declared timeout budget).
|
|
1404
|
+
let tst = null;
|
|
1405
|
+
try { tst = fs.statSync(ticketPath); } catch (_e) { /* vanished — retry the create below */ }
|
|
1406
|
+
if (tst === null) {
|
|
1407
|
+
try { ticketFd = fs.openSync(ticketPath, "wx"); } catch (_e) {}
|
|
1408
|
+
} else if (Date.now() - tst.mtimeMs > LOCK_TICKET_STALE_SECONDS * 1000) {
|
|
1409
|
+
// The ticket ITSELF is orphaned — reclaim it with the IDENTICAL identity-verified
|
|
1410
|
+
// discipline already used for the main lock just above, one level down: re-stat
|
|
1411
|
+
// immediately before unlinking and compare inode, so a ticket some THIRD, still-
|
|
1412
|
+
// legitimately-in-flight reclaimer freshly (re)created in the gap between our stat
|
|
1413
|
+
// and our unlink is never destroyed. A plain unconditional unlink-by-path here would
|
|
1414
|
+
// reopen the IDENTICAL TOCTOU hole this whole ticket mechanism exists to close, one
|
|
1415
|
+
// level down (e.g. a naive "just unlink if old" would let racer A destroy racer C's
|
|
1416
|
+
// brand-new, not-yet-stale ticket for the SAME generation, so A and C would BOTH
|
|
1417
|
+
// believe they are the sole reclaimer — the exact double-hold bug this ticket
|
|
1418
|
+
// mechanism was built to prevent, reintroduced one level down).
|
|
1419
|
+
let currentTino = null;
|
|
1420
|
+
try { currentTino = fs.statSync(ticketPath).ino; } catch (_e) { /* already gone */ }
|
|
1421
|
+
if (currentTino === tst.ino) {
|
|
1422
|
+
try { fs.unlinkSync(ticketPath); } catch (_e) {} // already gone — harmless
|
|
1423
|
+
}
|
|
1424
|
+
try { ticketFd = fs.openSync(ticketPath, "wx"); } catch (_e) {} // someone else won it
|
|
1425
|
+
}
|
|
1426
|
+
// else: the ticket is live and fresh — a genuine, currently-in-flight reclaimer;
|
|
1427
|
+
// ticketFd stays null and falls through to the shared deadline check below (previously
|
|
1428
|
+
// an unconditional `continue` back to the top of this same branch — now routed the same
|
|
1429
|
+
// as any other non-progress iteration, never a special case that could spin unboundedly
|
|
1430
|
+
// on its own).
|
|
1431
|
+
}
|
|
1432
|
+
if (ticketFd !== null) {
|
|
1433
|
+
try {
|
|
1434
|
+
fs.closeSync(ticketFd);
|
|
1435
|
+
// Winning the ticket proves we are the SOLE reclaimer for the generation we observed
|
|
1436
|
+
// (`st.ino`) — it does NOT prove `lockPath` is STILL that generation right now. An
|
|
1437
|
+
// arbitrarily long scheduling gap can separate "we judged st.ino stale" from "we act on
|
|
1438
|
+
// it," and in that gap this SAME path can already have cycled through a full, unrelated
|
|
1439
|
+
// reclaim by someone else (that old inode gone, a fresh one created and now actively
|
|
1440
|
+
// held — empirically observed while building this fix: a late-scheduled racer's ticket
|
|
1441
|
+
// for a long-superseded inode still "won" trivially, since nothing else was contesting
|
|
1442
|
+
// that specific stale ticket name any more, and its UNCONDITIONAL unlink then blindly
|
|
1443
|
+
// destroyed the CURRENT live holder's fresh file). Re-stat immediately before mutating
|
|
1444
|
+
// and compare inodes: only remove the file if it is STILL, right now, the exact
|
|
1445
|
+
// generation we ticketed for; otherwise our ticket is moot — leave the (unrelated,
|
|
1446
|
+
// currently live) file completely alone and let the loop's own open/EEXIST/age logic
|
|
1447
|
+
// re-evaluate reality fresh on the next iteration.
|
|
1448
|
+
let currentIno = null;
|
|
1449
|
+
try { currentIno = fs.statSync(lockPath).ino; } catch (_e) { /* already gone */ }
|
|
1450
|
+
if (currentIno === st.ino) {
|
|
1451
|
+
try { fs.unlinkSync(lockPath); } catch (_e) {} // already gone — harmless
|
|
1452
|
+
}
|
|
1453
|
+
} finally {
|
|
1454
|
+
try { fs.unlinkSync(ticketPath); } catch (_e) {} // best-effort cleanup of our own ticket
|
|
1455
|
+
}
|
|
1456
|
+
reclaimed = true;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
if (reclaimed) {
|
|
1460
|
+
continue; // retry the create immediately (self-heal)
|
|
1461
|
+
}
|
|
1462
|
+
if (deadline !== null && Date.now() < deadline) {
|
|
1463
|
+
sleepSync(LOCK_POLL_INTERVAL_MS);
|
|
1464
|
+
continue; // keep polling a LIVE holder — or a still-contested ticket — until the deadline.
|
|
1465
|
+
// This check is now ALWAYS reachable on every non-reclaiming iteration (the
|
|
1466
|
+
// fix's other half): a wedged/leaked ticket can no longer bypass it by looping
|
|
1467
|
+
// back to the top of the `while` unconditionally, so an explicit --lock-timeout
|
|
1468
|
+
// is honored even while a stale ticket is being self-healed above.
|
|
1469
|
+
}
|
|
1470
|
+
timedOut = true;
|
|
1471
|
+
break;
|
|
1086
1472
|
}
|
|
1473
|
+
if (timedOut) {
|
|
1474
|
+
fail("update_in_progress: another `update --global` is already running — retry shortly " +
|
|
1475
|
+
"(remove " + lockPath + " if it is stale)");
|
|
1476
|
+
return () => {}; // unreachable once fail() exits — keeps the function's return shape honest
|
|
1477
|
+
}
|
|
1478
|
+
try { fs.writeSync(fd, process.pid + " " + new Date().toISOString() + "\n"); }
|
|
1479
|
+
catch (_e) {} // diagnostics are best-effort — never fail an acquired lock over this
|
|
1087
1480
|
const release = () => {
|
|
1088
1481
|
try { fs.closeSync(fd); } catch (_e) {}
|
|
1089
1482
|
try { fs.unlinkSync(lockPath); } catch (_e) {}
|
|
@@ -1092,13 +1485,173 @@ function acquireUpdateLock(home) {
|
|
|
1092
1485
|
return release;
|
|
1093
1486
|
}
|
|
1094
1487
|
|
|
1488
|
+
// project-scope lock (project-scope-install-lock) — serializes cmdInit()/cmdUpdate() (non-
|
|
1489
|
+
// `--global` path) against the SAME target's own .add/ tree. A NEW, INDEPENDENT primitive that
|
|
1490
|
+
// mirrors (but never calls into or shares code with) acquireUpdateLock's own proven shape:
|
|
1491
|
+
// different function, different file, different default threshold, zero shared code (the two
|
|
1492
|
+
// locks guard genuinely different-shaped resources — see _installer.py:_project_lock).
|
|
1493
|
+
//
|
|
1494
|
+
// Held AND fresh -> fails IMMEDIATELY with "install_in_progress". UNLIKE acquireUpdateLock,
|
|
1495
|
+
// there is NO bounded-wait/poll mode (a live contention never waits, never polls — M7).
|
|
1496
|
+
//
|
|
1497
|
+
// Held AND stale (age > ADD_PROJECT_LOCK_STALE_SECONDS, default 120s) -> self-heals: unlinks
|
|
1498
|
+
// the stale lockfile and retries the create EXACTLY once before falling through to fail-fast.
|
|
1499
|
+
// A clock-skewed FUTURE mtime is NEVER treated as stale.
|
|
1500
|
+
//
|
|
1501
|
+
// KNOWN PROBLEM this shape works around: fail() calls process.exit(1) DIRECTLY (skips any
|
|
1502
|
+
// pending finally) — release is wired via process.on("exit", release), never a plain
|
|
1503
|
+
// try/finally at the call site (mirrors acquireUpdateLock's own already-solved precedent).
|
|
1504
|
+
//
|
|
1505
|
+
// If addDir did not exist yet (a virgin target — the lock file needs somewhere to live), it is
|
|
1506
|
+
// created here; on release, an addDir THIS call created is removed again iff it is still
|
|
1507
|
+
// completely empty (the lock file was its only occupant) — e.g. an --global failure (a held
|
|
1508
|
+
// home lock, an unwritable home) that aborts before the per-project drop leaves NOTHING behind,
|
|
1509
|
+
// exactly as before this lock existed. A non-empty addDir (the real drop landed, or it
|
|
1510
|
+
// pre-existed) is never touched.
|
|
1511
|
+
function acquireProjectLock(addDir, env = process.env) {
|
|
1512
|
+
const createdDir = !fs.existsSync(addDir);
|
|
1513
|
+
fs.mkdirSync(addDir, { recursive: true });
|
|
1514
|
+
const lockPath = path.join(addDir, PROJECT_LOCK_FILE);
|
|
1515
|
+
const staleAfterMs = Number(env.ADD_PROJECT_LOCK_STALE_SECONDS || PROJECT_LOCK_STALE_DEFAULT) * 1000;
|
|
1516
|
+
|
|
1517
|
+
let fd = null;
|
|
1518
|
+
try {
|
|
1519
|
+
fd = fs.openSync(lockPath, "wx");
|
|
1520
|
+
} catch (e) {
|
|
1521
|
+
if (!e || e.code !== "EEXIST") throw e;
|
|
1522
|
+
let st = null;
|
|
1523
|
+
try { st = fs.statSync(lockPath); } catch (_e) { /* vanished — treat as reclaimable now */ }
|
|
1524
|
+
if (st === null) {
|
|
1525
|
+
// vanished between the failed open and the stat — nothing to quarantine; retry a plain
|
|
1526
|
+
// create directly (safe unconditionally: O_EXCL is still the sole arbiter — this can
|
|
1527
|
+
// never clobber a legitimate concurrent holder's fresh file, it can only succeed if the
|
|
1528
|
+
// path is genuinely vacant right now).
|
|
1529
|
+
try {
|
|
1530
|
+
fd = fs.openSync(lockPath, "wx");
|
|
1531
|
+
} catch (e2) {
|
|
1532
|
+
if (!e2 || e2.code !== "EEXIST") throw e2;
|
|
1533
|
+
}
|
|
1534
|
+
} else if (Date.now() - st.mtimeMs > staleAfterMs) {
|
|
1535
|
+
// Identity-verified reclaim (fixes a TOCTOU race: an unconditional unlink-by-path let a
|
|
1536
|
+
// second racer delete a FIRST racer's already-recreated, live lock). A NAIVE "rename to a
|
|
1537
|
+
// quarantine name" does NOT fix this — a rename is JUST as identity-blind as an unlink: it
|
|
1538
|
+
// operates on whatever currently sits at `lockPath`, so a delayed racer's rename can just
|
|
1539
|
+
// as easily steal a WINNER's brand-new fresh file (empirically reproduced while building
|
|
1540
|
+
// this fix; that attempt made the race MEASURABLY WORSE, not better, by adding an extra
|
|
1541
|
+
// syscall to the vulnerable window). The actual fix: gate entry to the reclaim itself
|
|
1542
|
+
// behind a SEPARATE, per-GENERATION exclusive ticket, keyed to the CURRENT stale file's
|
|
1543
|
+
// own inode number (`st.ino` — stable for this file's lifetime, and a FRESH replacement
|
|
1544
|
+
// file always gets a NEW inode). Two racers that observe the SAME stale file compute the
|
|
1545
|
+
// IDENTICAL ticket name and race an exclusive create on IT — only one wins; every loser
|
|
1546
|
+
// backs off WITHOUT ever touching `lockPath` itself, so nobody can ever unlink/steal a
|
|
1547
|
+
// generation they didn't win the ticket for.
|
|
1548
|
+
const ticketPath = lockPath + ".reclaim-" + st.ino;
|
|
1549
|
+
let ticketFd = null;
|
|
1550
|
+
try {
|
|
1551
|
+
ticketFd = fs.openSync(ticketPath, "wx");
|
|
1552
|
+
} catch (_e) {
|
|
1553
|
+
// LOST the per-generation reclaim ticket outright -- checked below before treating this
|
|
1554
|
+
// as "someone else legitimately owns reclaiming THIS stale file right now."
|
|
1555
|
+
}
|
|
1556
|
+
if (ticketFd === null) {
|
|
1557
|
+
// LOST the ticket outright. A leaked ticket -- its own holder crashed between winning
|
|
1558
|
+
// it and its own best-effort cleanup below -- would otherwise wedge this generation's
|
|
1559
|
+
// reclaim PERMANENTLY: the ticket's name is deterministically keyed to `lockPath`'s own
|
|
1560
|
+
// (unchanging) inode, so every future contender recomputes the IDENTICAL ticket path and
|
|
1561
|
+
// loses the identical EEXIST race forever (found by a fresh adversarial verify pass;
|
|
1562
|
+
// independently reproduced here against the unmodified code).
|
|
1563
|
+
let tst = null;
|
|
1564
|
+
try { tst = fs.statSync(ticketPath); } catch (_e) { /* vanished — retry the create below */ }
|
|
1565
|
+
if (tst === null) {
|
|
1566
|
+
try { ticketFd = fs.openSync(ticketPath, "wx"); } catch (_e) {}
|
|
1567
|
+
} else if (Date.now() - tst.mtimeMs > PROJECT_LOCK_TICKET_STALE_SECONDS * 1000) {
|
|
1568
|
+
// The ticket ITSELF is orphaned — reclaim it with the IDENTICAL identity-verified
|
|
1569
|
+
// discipline already used for the main lock just above, one level down: re-stat
|
|
1570
|
+
// immediately before unlinking and compare inode, so a ticket some THIRD, still-
|
|
1571
|
+
// legitimately-in-flight reclaimer freshly (re)created in the gap between our stat and
|
|
1572
|
+
// our unlink is never destroyed. A plain unconditional unlink-by-path here would
|
|
1573
|
+
// reopen the IDENTICAL TOCTOU hole this whole ticket mechanism exists to close, one
|
|
1574
|
+
// level down (e.g. a naive "just unlink if old" would let racer A destroy racer C's
|
|
1575
|
+
// brand-new, not-yet-stale ticket for the SAME generation, so A and C would BOTH
|
|
1576
|
+
// believe they are the sole reclaimer — the exact double-hold bug this ticket
|
|
1577
|
+
// mechanism was built to prevent, reintroduced one level down). Exactly one extra
|
|
1578
|
+
// self-heal attempt — never a second, matching M7's own "no poll, ever" discipline
|
|
1579
|
+
// already governing the main lock's own reclaim above.
|
|
1580
|
+
let currentTino = null;
|
|
1581
|
+
try { currentTino = fs.statSync(ticketPath).ino; } catch (_e) { /* already gone */ }
|
|
1582
|
+
if (currentTino === tst.ino) {
|
|
1583
|
+
try { fs.unlinkSync(ticketPath); } catch (_e) {} // already gone — harmless
|
|
1584
|
+
}
|
|
1585
|
+
try { ticketFd = fs.openSync(ticketPath, "wx"); } catch (_e) {} // someone else won it
|
|
1586
|
+
}
|
|
1587
|
+
// else: the ticket is live and fresh — a genuine, currently-in-flight reclaimer;
|
|
1588
|
+
// ticketFd stays null, falls through to the SAME fail-fast below (M7 — this lock never
|
|
1589
|
+
// polls a live holder, whether it is the main lock or a contested ticket).
|
|
1590
|
+
}
|
|
1591
|
+
if (ticketFd !== null) {
|
|
1592
|
+
try {
|
|
1593
|
+
fs.closeSync(ticketFd);
|
|
1594
|
+
// Winning the ticket proves we are the SOLE reclaimer for the generation we observed
|
|
1595
|
+
// (`st.ino`) — it does NOT prove `lockPath` is STILL that generation right now. An
|
|
1596
|
+
// arbitrarily long scheduling gap can separate "we judged st.ino stale" from "we act
|
|
1597
|
+
// on it," and in that gap this SAME path can already have cycled through a full,
|
|
1598
|
+
// unrelated reclaim by someone else (that old inode gone, a fresh one created and now
|
|
1599
|
+
// actively held — empirically observed while building this fix: a late-scheduled
|
|
1600
|
+
// racer's ticket for a long-superseded inode still "won" trivially, since nothing else
|
|
1601
|
+
// was contesting that specific stale ticket name any more, and its UNCONDITIONAL
|
|
1602
|
+
// unlink then blindly destroyed the CURRENT live holder's fresh file). Re-stat
|
|
1603
|
+
// immediately before mutating and compare inodes: only remove the file if it is
|
|
1604
|
+
// STILL, right now, the exact generation we ticketed for; otherwise our ticket is
|
|
1605
|
+
// moot — leave the (unrelated, currently live) file completely alone and let the
|
|
1606
|
+
// single retry below observe reality fresh (EEXIST -> fail-fast, per M7 — this lock
|
|
1607
|
+
// never polls a live holder).
|
|
1608
|
+
let currentIno = null;
|
|
1609
|
+
try { currentIno = fs.statSync(lockPath).ino; } catch (_e) { /* already gone */ }
|
|
1610
|
+
if (currentIno === st.ino) {
|
|
1611
|
+
try { fs.unlinkSync(lockPath); } catch (_e) {} // already gone — harmless
|
|
1612
|
+
}
|
|
1613
|
+
} finally {
|
|
1614
|
+
try { fs.unlinkSync(ticketPath); } catch (_e) {} // best-effort cleanup of our own ticket
|
|
1615
|
+
}
|
|
1616
|
+
try {
|
|
1617
|
+
fd = fs.openSync(lockPath, "wx");
|
|
1618
|
+
} catch (e2) {
|
|
1619
|
+
if (!e2 || e2.code !== "EEXIST") throw e2;
|
|
1620
|
+
// someone else created a fresh file at the just-vacated path before we did (e.g. a
|
|
1621
|
+
// brand-new, never-raced contender's own first attempt landed in the gap) -> fail-fast,
|
|
1622
|
+
// exactly once — never a second reclaim attempt, never a poll/wait (M7).
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
if (fd === null) {
|
|
1627
|
+
fail("install_in_progress: another install/update is already running against " +
|
|
1628
|
+
path.dirname(addDir) + " — retry shortly (remove " + lockPath + " if stale)");
|
|
1629
|
+
return () => {}; // unreachable once fail() exits — keeps the function's return shape honest
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
try { fs.writeSync(fd, process.pid + " " + new Date().toISOString() + "\n"); }
|
|
1633
|
+
catch (_e) {} // diagnostics are best-effort — never fail an acquired lock over this
|
|
1634
|
+
const release = () => {
|
|
1635
|
+
try { fs.closeSync(fd); } catch (_e) {}
|
|
1636
|
+
try { fs.unlinkSync(lockPath); } catch (_e) {}
|
|
1637
|
+
if (createdDir) {
|
|
1638
|
+
try { if (fs.readdirSync(addDir).length === 0) fs.rmdirSync(addDir); } catch (_e) {}
|
|
1639
|
+
// non-empty (the real drop landed) or already gone — leave it alone either way
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
process.on("exit", release); // covers normal completion AND fail()'s process.exit
|
|
1643
|
+
return release;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1095
1646
|
function cmdUpdateGlobal(args) {
|
|
1096
1647
|
const home = resolveGlobalHome(process.env);
|
|
1097
1648
|
const claudeDir = claudeSkillsDir(process.env);
|
|
1098
1649
|
if (!fs.existsSync(path.join(home, STAMP_FILE))) {
|
|
1099
1650
|
fail("no_global_home: no global ADD install at " + home + " (.add-version not found) — run `init --global` first");
|
|
1100
1651
|
}
|
|
1101
|
-
|
|
1652
|
+
// exclusive; self-heals a stale lock; a LIVE lock -> update_in_progress (immediate, or after
|
|
1653
|
+
// waiting up to --lock-timeout seconds); released on process exit
|
|
1654
|
+
acquireUpdateLock(home, { timeout: args.lockTimeout }, process.env);
|
|
1102
1655
|
// Read the registry BEFORE refreshing the home — a corrupt registry fails closed with ZERO
|
|
1103
1656
|
// writes (never a silent empty-list no-op), leaving the file for the user to fix or delete.
|
|
1104
1657
|
let reg;
|
|
@@ -1123,6 +1676,7 @@ function cmdUpdateGlobal(args) {
|
|
|
1123
1676
|
log(" ⚠ registered path " + np + " is not an ADD project (no .add/) — dropping"); dropped++; continue;
|
|
1124
1677
|
}
|
|
1125
1678
|
reconcile(args, np, home); // standard MANAGED map, sourced from the home mirror
|
|
1679
|
+
seedGitignore(np); // keep .add/.gitignore current too (parity: _installer.py)
|
|
1126
1680
|
// re-persist an opted-in project (one that already has a snapshot); a vanished
|
|
1127
1681
|
// project's snapshot is KEPT above (the backup outlives the dir).
|
|
1128
1682
|
if (fs.existsSync(path.join(home, "data", dataKey(np)))) persistData(home, np);
|
|
@@ -1153,10 +1707,17 @@ function cmdUpdate(args) {
|
|
|
1153
1707
|
else log("ADD update available: project on " + cur + ", package is " + version + ". Run `update`.");
|
|
1154
1708
|
return;
|
|
1155
1709
|
}
|
|
1710
|
+
// Project-scope lock (project-scope-install-lock): acquired AFTER the read-only --check
|
|
1711
|
+
// report (a JS-only carve-out — see TASK.md §0/M3), held from here through the function's
|
|
1712
|
+
// end — INCLUDING the same-version no-op check below, so a retried call re-evaluates it fresh.
|
|
1713
|
+
acquireProjectLock(addDir);
|
|
1156
1714
|
// same-version no-op ONLY when nothing is missing — a missing managed tree HEALS
|
|
1157
1715
|
// even at the current version (heal-reconcile).
|
|
1158
1716
|
const status = managedStatus(target);
|
|
1159
|
-
|
|
1717
|
+
// An optional tree absent from BOTH the package and the project can't be healed, so it
|
|
1718
|
+
// never counts as "missing" — otherwise a same-version update would never reach the no-op.
|
|
1719
|
+
const missing = MANAGED.some(([sub]) => status[sub] === "missing"
|
|
1720
|
+
&& !(OPTIONAL.has(sub) && !fs.existsSync(path.join(PKG_ROOT, sub))));
|
|
1160
1721
|
if (cur === version && !args.force && !missing) {
|
|
1161
1722
|
log("ADD already at " + version + " — nothing to update (use --force to re-materialize).");
|
|
1162
1723
|
return;
|