@polymorphism-tech/morph-spec 9.0.0 → 9.1.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/CLAUDE.md +8 -9
- package/README.md +38 -36
- package/bin/morph-spec.js +4 -62
- package/bin/validate.js +1 -39
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +1 -1
- package/framework/MORPH.md +1 -1
- package/framework/agents.json +12 -4
- package/framework/commands/morph-apply.md +1 -1
- package/framework/commands/morph-status.md +1 -1
- package/framework/docs/CLI.md +6 -17
- package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +6 -49
- package/framework/hooks/claude-code/core/block-raw-worktree-add.js +2 -35
- package/framework/hooks/claude-code/core/chain.js +4 -65
- package/framework/hooks/claude-code/core/comment-guard.js +2 -16
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +6 -50
- package/framework/hooks/claude-code/core/gate-guard.js +7 -67
- package/framework/hooks/claude-code/core/graph-refresh-kick.js +3 -24
- package/framework/hooks/claude-code/core/heavy-gate.js +3 -21
- package/framework/hooks/claude-code/core/heavy-release.js +2 -13
- package/framework/hooks/claude-code/core/loop-detect.js +2 -31
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +0 -4
- package/framework/hooks/claude-code/core/protect-spec-files.js +7 -67
- package/framework/hooks/claude-code/core/state-sync.js +11 -67
- package/framework/hooks/claude-code/core/telemetry-log.js +12 -93
- package/framework/hooks/claude-code/core/validate-completion.js +11 -57
- package/framework/hooks/claude-code/core/warn-bash-source-write.js +1 -9
- package/framework/hooks/claude-code/core/worktree-context.js +10 -110
- package/framework/hooks/claude-code/core/worktree-create.js +6 -70
- package/framework/hooks/claude-code/core/worktree-env-inject.js +5 -56
- package/framework/hooks/claude-code/core/worktree-remove.js +5 -31
- package/framework/hooks/shared/activity-logger.js +9 -89
- package/framework/hooks/shared/agent-reap.js +2 -17
- package/framework/hooks/shared/build-check.js +6 -75
- package/framework/hooks/shared/claim-registry.js +9 -119
- package/framework/hooks/shared/command-tokens.js +6 -51
- package/framework/hooks/shared/comment-rules.js +5 -20
- package/framework/hooks/shared/comment-scanner.js +6 -25
- package/framework/hooks/shared/comment-stripper.js +11 -85
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/errata.js +2 -15
- package/framework/hooks/shared/feature-locator.js +11 -95
- package/framework/hooks/shared/frontend-locations.js +1 -3
- package/framework/hooks/shared/gate-question.js +1 -7
- package/framework/hooks/shared/git-exclude.js +3 -20
- package/framework/hooks/shared/git-paths.js +8 -104
- package/framework/hooks/shared/graph-freshness.js +22 -166
- package/framework/hooks/shared/graph-refresh.js +21 -149
- package/framework/hooks/shared/heavy-command.js +4 -12
- package/framework/hooks/shared/hook-response.js +8 -83
- package/framework/hooks/shared/morph-cli.js +8 -52
- package/framework/hooks/shared/payload-utils.js +10 -132
- package/framework/hooks/shared/phase-utils.js +1 -42
- package/framework/hooks/shared/redact-secrets.js +4 -15
- package/framework/hooks/shared/reparse-scan.js +6 -66
- package/framework/hooks/shared/slice-progress.js +4 -23
- package/framework/hooks/shared/source-files.js +4 -17
- package/framework/hooks/shared/state-reader.js +13 -183
- package/framework/hooks/shared/state-writer.js +8 -52
- package/framework/hooks/shared/stdin-reader.js +1 -11
- package/framework/hooks/shared/telemetry-logger.js +7 -67
- package/framework/hooks/shared/transcript-usage.js +7 -29
- package/framework/hooks/shared/worktree-root.js +7 -29
- package/framework/rules/csharp-standards.md +1 -1
- package/framework/rules/frontend-standards.md +1 -0
- package/framework/rules/hangfire-standards.md +1 -1
- package/framework/rules/neon-standards.md +1 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +2 -2
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-machine/SKILL.md +1 -1
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +2 -2
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +29 -12
- package/framework/standards/STANDARDS.json +34 -12
- package/framework/standards/STANDARDS.meta.json +16 -2
- package/framework/standards/ai-agents/agent-archetypes.md +2 -2
- package/framework/standards/ai-agents/agent-session.md +1 -1
- package/framework/standards/ai-agents/conversational-agent-with-phases.md +4 -4
- package/framework/standards/ai-agents/durable-workflows-hitl.md +3 -3
- package/framework/standards/ai-agents/guardrails.md +1 -1
- package/framework/standards/ai-agents/multi-agent-patterns.md +2 -2
- package/framework/standards/ai-agents/observability-patterns.md +1 -1
- package/framework/standards/ai-agents/production.md +1 -1
- package/framework/standards/ai-agents/prompt-sources.md +2 -2
- package/framework/standards/ai-agents/providers/model-registry.md +1 -1
- package/framework/standards/ai-agents/rag-custom-pgvector.md +2 -2
- package/framework/standards/ai-agents/setup.md +2 -2
- package/framework/standards/ai-agents/sweet-spot.md +2 -2
- package/framework/standards/ai-agents/tool-adapters.md +0 -2
- package/framework/standards/ai-agents/vector-data-extensions.md +4 -4
- package/framework/standards/ai-agents/workflows.md +1 -1
- package/framework/standards/architecture/code-comments.md +1 -1
- package/framework/standards/architecture/vertical-slice/vertical-slice.md +1 -1
- package/framework/standards/backend/api/validation.md +1 -1
- package/framework/standards/backend/dotnet/vsa-handler-patterns.md +5 -5
- package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +1 -1
- package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -2
- package/framework/standards/data/nosql/cache/redis.md +1 -1
- package/framework/standards/data/nosql/cosmos-db.md +3 -3
- package/framework/standards/frontend/admin-app/forms.md +1 -1
- package/framework/standards/frontend/design-system/art-direction.md +88 -0
- package/framework/standards/frontend/design-system/premium-finish.md +77 -2
- package/framework/standards/infrastructure/neon/neon-pgvector.md +5 -5
- package/framework/templates/CLAUDE.md.core.template +3 -3
- package/framework/templates/REGISTRY.json +9 -9
- package/framework/templates/docs/ui-mockups.md +4 -0
- package/package.json +1 -1
- package/src/commands/context/context-refresh.js +0 -16
- package/src/commands/dashboard/dashboard.js +2 -8
- package/src/commands/e2e/e2e.js +17 -104
- package/src/commands/graph/affected.js +0 -14
- package/src/commands/graph/build.js +11 -50
- package/src/commands/graph/explain.js +0 -16
- package/src/commands/graph/status.js +1 -12
- package/src/commands/mcp/mcp-setup.js +4 -62
- package/src/commands/observability/cost.js +5 -57
- package/src/commands/observability/telemetry.js +1 -14
- package/src/commands/project/branch.js +4 -45
- package/src/commands/project/claim.js +4 -48
- package/src/commands/project/doctor.js +48 -302
- package/src/commands/project/env.js +1 -27
- package/src/commands/project/fleet.js +3 -32
- package/src/commands/project/init.js +4 -68
- package/src/commands/project/install-plugin-cmd.js +0 -1
- package/src/commands/project/standards.js +1 -62
- package/src/commands/project/status.js +0 -53
- package/src/commands/project/update.js +31 -239
- package/src/commands/project/worktree.js +108 -882
- package/src/commands/scaffold/scaffold.js +0 -25
- package/src/commands/state/advance.js +14 -85
- package/src/commands/state/approve.js +14 -96
- package/src/commands/state/archive.js +12 -64
- package/src/commands/state/auto-approval.js +2 -5
- package/src/commands/state/create.js +22 -109
- package/src/commands/state/delete.js +11 -63
- package/src/commands/state/finish.js +98 -475
- package/src/commands/state/gate-check.js +22 -132
- package/src/commands/state/gate-decision.js +3 -39
- package/src/commands/state/handoff.js +3 -30
- package/src/commands/state/list.js +14 -84
- package/src/commands/state/report-check.js +8 -58
- package/src/commands/state/retype.js +4 -26
- package/src/commands/validation/index.js +0 -3
- package/src/commands/validation/mutate.js +2 -5
- package/src/commands/validation/validate-feature.js +5 -94
- package/src/commands/validation/verify.js +15 -98
- package/src/core/paths/output-schema.js +2 -101
- package/src/core/state/feature-identity.js +1 -25
- package/src/core/state/gate-order.js +0 -27
- package/src/core/state/lifecycle-audit.js +8 -100
- package/src/core/state/state-manager.js +22 -304
- package/src/core/templates/template-registry.js +2 -97
- package/src/core/templates/template-renderer.js +13 -93
- package/src/core/templates/template-seed.js +0 -1
- package/src/core/work-types.js +1 -36
- package/src/dashboard/app/App.jsx +1 -8
- package/src/dashboard/app/components/ActivityFeed.jsx +0 -5
- package/src/dashboard/app/components/CommandPalette.jsx +0 -7
- package/src/dashboard/app/components/DetailPanel.jsx +1 -5
- package/src/dashboard/app/components/EntityLink.jsx +0 -12
- package/src/dashboard/app/components/ErrorBoundary.jsx +1 -8
- package/src/dashboard/app/components/KnowledgeMarkdown.jsx +0 -11
- package/src/dashboard/app/components/Markdown.jsx +0 -9
- package/src/dashboard/app/components/PhaseRail.jsx +0 -11
- package/src/dashboard/app/components/SearchFilter.jsx +0 -4
- package/src/dashboard/app/components/Sidebar.jsx +0 -5
- package/src/dashboard/app/hooks/useMorphQuery.js +1 -11
- package/src/dashboard/app/hooks/usePref.js +1 -5
- package/src/dashboard/app/lib/format.js +0 -2
- package/src/dashboard/app/lib/phases.js +1 -5
- package/src/dashboard/app/lib/project.js +0 -12
- package/src/dashboard/app/pages/AgentMap.jsx +1 -8
- package/src/dashboard/app/pages/FeatureDetail.jsx +1 -5
- package/src/dashboard/app/pages/Features.jsx +1 -8
- package/src/dashboard/app/pages/Guide.jsx +0 -1
- package/src/dashboard/app/pages/HookInspector.jsx +2 -23
- package/src/dashboard/app/pages/PhasePipeline.jsx +0 -3
- package/src/dashboard/app/pages/Portfolio.jsx +0 -1
- package/src/dashboard/app/pages/RulesMap.jsx +1 -16
- package/src/dashboard/app/pages/SkillsLibrary.jsx +1 -16
- package/src/dashboard/app/pages/Standards.jsx +2 -17
- package/src/dashboard/scanner/activity-scanner.js +10 -69
- package/src/dashboard/scanner/agents-scanner.js +1 -23
- package/src/dashboard/scanner/docs-scanner.js +0 -6
- package/src/dashboard/scanner/feature-status.js +2 -5
- package/src/dashboard/scanner/features-scanner.js +10 -66
- package/src/dashboard/scanner/hooks-scanner.js +5 -28
- package/src/dashboard/scanner/index.js +0 -4
- package/src/dashboard/scanner/portfolio.js +5 -29
- package/src/dashboard/scanner/project-api.js +8 -54
- package/src/dashboard/scanner/rules-scanner.js +1 -2
- package/src/dashboard/scanner/skills-scanner.js +2 -12
- package/src/dashboard/scanner/standards-scanner.js +2 -6
- package/src/dashboard/vite-plugin-morph-data.js +5 -19
- package/src/lib/ai/ai-checks.js +7 -52
- package/src/lib/ai/ai-project-scan.js +14 -108
- package/src/lib/ai/code-audit.js +7 -63
- package/src/lib/ai/pin-drift.js +8 -51
- package/src/lib/ai/registry-audit.js +11 -90
- package/src/lib/ci/ci-verify.js +4 -19
- package/src/lib/ci/github-ci.js +9 -37
- package/src/lib/ci/workflow-scaffold.js +2 -10
- package/src/lib/detectors/claude-config-detector.js +13 -74
- package/src/lib/detectors/index.js +4 -50
- package/src/lib/e2e/compose-env.js +5 -66
- package/src/lib/e2e/stack-teardown.js +6 -40
- package/src/lib/generators/architecture-scanner.js +18 -156
- package/src/lib/generators/handoff-generator.js +9 -53
- package/src/lib/generators/scaffold-generator.js +5 -101
- package/src/lib/generators/settings-generator.js +4 -37
- package/src/lib/git/branch-classifier.js +11 -84
- package/src/lib/git/default-branch.js +14 -169
- package/src/lib/git/evidence-staleness.js +1 -0
- package/src/lib/git/gh.js +2 -17
- package/src/lib/git/pending-prs.js +6 -37
- package/src/lib/git/pr-files.js +3 -28
- package/src/lib/git/pr-index.js +3 -34
- package/src/lib/git/tree-fingerprint.js +3 -15
- package/src/lib/git/tree-state.js +6 -33
- package/src/lib/graph/god-nodes.js +2 -22
- package/src/lib/graph/graph-health.js +3 -24
- package/src/lib/graph/graph-loader.js +5 -56
- package/src/lib/graph/graphify-runtime.js +7 -37
- package/src/lib/graph/graphifyignore.js +8 -74
- package/src/lib/graph/sanitize.js +16 -136
- package/src/lib/heavy/cleanup.js +1 -1
- package/src/lib/installers/effects.js +8 -93
- package/src/lib/installers/mcp-installer.js +9 -169
- package/src/lib/installers/memory-installer.js +4 -27
- package/src/lib/installers/templates-reconcile.js +5 -60
- package/src/lib/mutation/dotnet-introspection.js +20 -106
- package/src/lib/mutation/subset.js +1 -4
- package/src/lib/plan/artifact-vocabulary.js +5 -25
- package/src/lib/plan/checks.js +5 -31
- package/src/lib/plan/plan-parser.js +1 -6
- package/src/lib/projects/registry.js +7 -44
- package/src/lib/review-reports.js +5 -23
- package/src/lib/risk-detector.js +39 -173
- package/src/lib/specs/adr-index.js +6 -34
- package/src/lib/specs/frontmatter.js +2 -15
- package/src/lib/specs/gfm-table.js +1 -7
- package/src/lib/specs/revocations.js +1 -14
- package/src/lib/specs/screen-checklist.js +47 -124
- package/src/lib/stack/dotnet-build-ignores.js +2 -6
- package/src/lib/stack/stack-profile.js +1 -28
- package/src/lib/stack/stack-scan.js +1 -8
- package/src/lib/stack-filter.js +4 -27
- package/src/lib/standards/registry.js +1 -7
- package/src/lib/standards/sections.js +2 -13
- package/src/lib/standards/verified-against.js +19 -191
- package/src/lib/tasks/eval-node.js +21 -169
- package/src/lib/tasks/linux-runner.js +18 -142
- package/src/lib/tasks/test-nodes.js +43 -240
- package/src/lib/tasks/test-runner.js +43 -341
- package/src/lib/telemetry/metrics.js +7 -36
- package/src/lib/telemetry/telemetry-reader.js +13 -125
- package/src/lib/telemetry/verify-event.js +2 -30
- package/src/lib/validators/code-budget/code-budget-validator.js +3 -43
- package/src/lib/validators/code-budget/index.js +0 -7
- package/src/lib/validators/contracts/contract-compliance-validator.js +2 -24
- package/src/lib/validators/contracts/index.js +0 -6
- package/src/lib/validators/nextjs/index.js +0 -5
- package/src/lib/validators/nextjs/next-component-validator.js +4 -81
- package/src/lib/validators/packages/index.js +0 -6
- package/src/lib/validators/packages/package-validator.js +6 -56
- package/src/lib/validators/reuse/index.js +0 -8
- package/src/lib/validators/reuse/reuse-validator.js +11 -101
- package/src/lib/validators/reuse/symbol-scanner.js +6 -62
- package/src/lib/validators/traceability-check.js +5 -63
- package/src/lib/validators/validation-runner.js +20 -125
- package/src/lib/validators/verify-runner.js +52 -358
- package/src/lib/work-type-detector.js +5 -67
- package/src/lib/worktree/docker-audit.js +17 -136
- package/src/lib/worktree/env-allocator.js +10 -98
- package/src/lib/worktree/feature-truth.js +12 -115
- package/src/lib/worktree/finish-backup.js +32 -242
- package/src/lib/worktree/fleet-scanner.js +13 -95
- package/src/lib/worktree/ignored-triage.js +15 -91
- package/src/lib/worktree/vscode-settings.js +2 -3
- package/src/lib/worktree/worktree-audit.js +21 -213
- package/src/lib/worktree/worktree-include.js +3 -5
- package/src/scripts/global-install.js +3 -9
- package/src/scripts/install-plugin.js +1 -13
- package/src/scripts/setup-infra.js +17 -147
- package/src/utils/agents-installer.js +28 -275
- package/src/utils/banner.js +4 -25
- package/src/utils/claude-md-injector.js +1 -37
- package/src/utils/claude-settings-manager.js +1 -52
- package/src/utils/file-copier.js +11 -95
- package/src/utils/hooks-installer.js +21 -298
- package/src/utils/skills-installer.js +4 -101
- package/src/utils/version-checker.js +4 -83
- package/framework/commands/morph-proposal.md +0 -9
- package/src/commands/utils/index.js +0 -3
- package/src/lib/detectors/design-system-detector.js +0 -188
- package/src/lib/generators/design-system-generator.js +0 -508
- package/src/lib/specs/old-format.js +0 -68
- package/src/utils/agent-id-mapper.js +0 -11
|
@@ -1,25 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
* Event: WorktreeRemove (Claude Code's own cleanup of a tree it created via
|
|
6
|
-
* `claude -w` / EnterWorktree / Agent isolation). Input carries
|
|
7
|
-
* `worktree_path` (absolute). No decision control, output ignored — this hook
|
|
8
|
-
* ALWAYS exits 0.
|
|
9
|
-
*
|
|
10
|
-
* Why it exists: Claude Code's native removal is a raw `git worktree remove`,
|
|
11
|
-
* which FOLLOWS the infra junctions the WorktreeCreate hook put in that tree
|
|
12
|
-
* and empties the primary root's `.claude/` and `.morph/framework/`. This
|
|
13
|
-
* hook routes the removal through `morph-spec worktree remove --target
|
|
14
|
-
* --if-clean` instead: junctions dropped first, PROVE-or-DECLINE, and a tree
|
|
15
|
-
* holding uncommitted work is left alone (Claude Code's notion of "clean" is
|
|
16
|
-
* not the user's).
|
|
17
|
-
*
|
|
18
|
-
* Secondary net: in headless runs the event was observed NOT to fire; the
|
|
19
|
-
* PreToolUse guard (block-destructive-worktree-removal) stays the primary
|
|
20
|
-
* defense. Fail-open everywhere — diagnostics to stderr, never a throw, never
|
|
21
|
-
* a non-zero exit.
|
|
22
|
-
*/
|
|
2
|
+
// Routes Claude Code's native removal (a raw `git worktree remove`, which follows the infra junctions out) through
|
|
3
|
+
// `morph-spec worktree remove --if-clean`. The event ignores output, so this hook always exits 0.
|
|
23
4
|
|
|
24
5
|
import { existsSync } from 'fs';
|
|
25
6
|
import { execFileSync } from 'child_process';
|
|
@@ -35,15 +16,11 @@ function primaryRoot(startDir) {
|
|
|
35
16
|
const out = execFileSync('git', ['worktree', 'list', '--porcelain'], { cwd: startDir, stdio: ['ignore', 'pipe', 'pipe'] }).toString();
|
|
36
17
|
const first = out.split('\n').find((l) => l.startsWith('worktree '));
|
|
37
18
|
if (first) return fwd(first.slice('worktree '.length).trim());
|
|
38
|
-
} catch {
|
|
19
|
+
} catch {}
|
|
39
20
|
const common = resolveGitCommonDir(startDir);
|
|
40
21
|
return common ? fwd(dirname(common)) : null;
|
|
41
22
|
}
|
|
42
23
|
|
|
43
|
-
/**
|
|
44
|
-
* @param {object} payload - parsed WorktreeRemove stdin payload ({ cwd, worktree_path })
|
|
45
|
-
* @returns {Promise<null>} always null — the event ignores output
|
|
46
|
-
*/
|
|
47
24
|
export async function run(payload) {
|
|
48
25
|
try {
|
|
49
26
|
const target = payload?.worktree_path;
|
|
@@ -52,8 +29,7 @@ export async function run(payload) {
|
|
|
52
29
|
const start = payload?.cwd || process.env.CLAUDE_PROJECT_DIR || dirname(resolve(target));
|
|
53
30
|
const root = primaryRoot(start) || primaryRoot(dirname(resolve(target)));
|
|
54
31
|
if (!root) return null;
|
|
55
|
-
//
|
|
56
|
-
// from here either: the tree is simply left for git's own cleanup.
|
|
32
|
+
// Never a raw removal from here: outside a morph project the tree is left for git's own cleanup.
|
|
57
33
|
if (!stateExists(root)) return null;
|
|
58
34
|
|
|
59
35
|
const r = runMorphSpec(['worktree', 'remove', '--target', fwd(resolve(target)), '--if-clean', '--json'], { cwd: root });
|
|
@@ -72,10 +48,8 @@ export async function run(payload) {
|
|
|
72
48
|
}
|
|
73
49
|
}
|
|
74
50
|
|
|
75
|
-
// Standalone tail: output is ignored by this event, so nothing is printed;
|
|
76
|
-
// the exit code is always 0.
|
|
77
51
|
if (!process.env.MORPH_HOOK_CHAINED) {
|
|
78
52
|
const { readStdin } = await import('../../shared/stdin-reader.js');
|
|
79
|
-
try { await run(await readStdin()); } catch {
|
|
53
|
+
try { await run(await readStdin()); } catch {}
|
|
80
54
|
process.exit(0);
|
|
81
55
|
}
|
|
@@ -1,28 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Writes hook activity to .morph/logs/activity.jsonl for live consumption
|
|
5
|
-
* (statusline, `morph-spec fleet`, etc).
|
|
6
|
-
*
|
|
7
|
-
* Design constraints:
|
|
8
|
-
* - Synchronous only (hooks run in process, no async overhead)
|
|
9
|
-
* - Fail-silent (never throws — hooks must not be blocked by the logger)
|
|
10
|
-
* - ≤5ms per call (no retries, no network)
|
|
11
|
-
* - APPEND-ONLY: one `appendFileSync` line per event, O(1) per call. The
|
|
12
|
-
* previous format did a read-modify-write of the WHOLE JSON doc per call
|
|
13
|
-
* (logHookActivity/logEvent both read, mutated, and rewrote the entire
|
|
14
|
-
* file) — O(n²) across a session once tool calls number in the hundreds,
|
|
15
|
-
* with 6 hooks each calling this 3-5x per Write. Same discipline as
|
|
16
|
-
* telemetry-logger.js's events.jsonl.
|
|
17
|
-
* - Windows-safe paths via path.join
|
|
18
|
-
*
|
|
19
|
-
* Exported names/signatures are unchanged from the previous doc-based
|
|
20
|
-
* implementation on purpose — hooks outside this file's ownership
|
|
21
|
-
* (gate-guard.js, enforce-phase-writes.js,
|
|
22
|
-
* loop-detect.js, protect-spec-files.js, validate-completion.js) call
|
|
23
|
-
* logHookActivity/logEvent and must keep working untouched. Only the
|
|
24
|
-
* on-disk format changed.
|
|
25
|
-
*/
|
|
1
|
+
// Append-only on purpose: rewriting a whole JSON doc per event is O(n²) across
|
|
2
|
+
// a session. Fail-silent: never blocks a hook.
|
|
26
3
|
|
|
27
4
|
import {
|
|
28
5
|
appendFileSync, readFileSync, mkdirSync, existsSync, statSync, renameSync, writeFileSync,
|
|
@@ -32,10 +9,7 @@ import { join } from 'path';
|
|
|
32
9
|
const LOGS_DIR = '.morph/logs';
|
|
33
10
|
export const ACTIVITY_FILE = join(LOGS_DIR, 'activity.jsonl');
|
|
34
11
|
|
|
35
|
-
|
|
36
|
-
// size — the archive/rotation call site resetActivity()/archiveSession()
|
|
37
|
-
// were written for but never got until now.
|
|
38
|
-
export const ROTATE_THRESHOLD_BYTES = 2 * 1024 * 1024; // 2 MB
|
|
12
|
+
export const ROTATE_THRESHOLD_BYTES = 2 * 1024 * 1024;
|
|
39
13
|
|
|
40
14
|
function getActivityPath(projectPath) {
|
|
41
15
|
return join(projectPath || process.cwd(), ACTIVITY_FILE);
|
|
@@ -49,20 +23,17 @@ function ensureLogsDir(projectPath) {
|
|
|
49
23
|
}
|
|
50
24
|
|
|
51
25
|
function nowTs() {
|
|
52
|
-
return new Date().toTimeString().slice(0, 8);
|
|
26
|
+
return new Date().toTimeString().slice(0, 8);
|
|
53
27
|
}
|
|
54
28
|
|
|
55
|
-
/** Append one JSON line. Fail-silent, O(1) regardless of file size. */
|
|
56
29
|
function appendLine(projectPath, line) {
|
|
57
30
|
try {
|
|
58
31
|
ensureLogsDir(projectPath);
|
|
59
32
|
appendFileSync(getActivityPath(projectPath), JSON.stringify(line) + '\n', 'utf-8');
|
|
60
33
|
} catch {
|
|
61
|
-
// Fail-silent — never block hooks
|
|
62
34
|
}
|
|
63
35
|
}
|
|
64
36
|
|
|
65
|
-
/** Read every parseable line back. A corrupt/truncated line is skipped, not fatal. */
|
|
66
37
|
function readLines(projectPath) {
|
|
67
38
|
try {
|
|
68
39
|
const activityPath = getActivityPath(projectPath);
|
|
@@ -75,7 +46,7 @@ function readLines(projectPath) {
|
|
|
75
46
|
try {
|
|
76
47
|
lines.push(JSON.parse(trimmed));
|
|
77
48
|
} catch {
|
|
78
|
-
//
|
|
49
|
+
// a process killed mid-write leaves a truncated line
|
|
79
50
|
}
|
|
80
51
|
}
|
|
81
52
|
return lines;
|
|
@@ -84,38 +55,15 @@ function readLines(projectPath) {
|
|
|
84
55
|
}
|
|
85
56
|
}
|
|
86
57
|
|
|
87
|
-
/**
|
|
88
|
-
* Log a hook firing event to the session activity log.
|
|
89
|
-
*
|
|
90
|
-
* @param {string} name - Hook name (e.g. 'dispatch.js', 'enrich-prompt')
|
|
91
|
-
* @param {string} event - Claude Code event (e.g. 'PostToolUse', 'UserPromptSubmit')
|
|
92
|
-
* @param {string} result - Outcome summary (e.g. 'ok', 'blocked', 'checkpoint_saved')
|
|
93
|
-
* @param {string} [projectPath] - Project root (defaults to cwd)
|
|
94
|
-
*/
|
|
95
58
|
export function logHookActivity(name, event, result, projectPath) {
|
|
96
59
|
appendLine(projectPath, { kind: 'hook', name, event, result, ts: nowTs() });
|
|
97
60
|
}
|
|
98
61
|
|
|
99
|
-
/**
|
|
100
|
-
* Log a structured event to the session activity log.
|
|
101
|
-
*
|
|
102
|
-
* @param {string} type - Event type (e.g., 'task_start', 'loop_detected')
|
|
103
|
-
* @param {Object} data - Event data payload
|
|
104
|
-
* @param {string} [projectPath] - Project root (defaults to cwd)
|
|
105
|
-
*/
|
|
106
62
|
export function logEvent(type, data, projectPath) {
|
|
107
63
|
appendLine(projectPath, { kind: 'event', type, data, ts: nowTs() });
|
|
108
64
|
}
|
|
109
65
|
|
|
110
|
-
/**
|
|
111
|
-
* Read the current activity log, reconstructed into the aggregate shape
|
|
112
|
-
* older consumers expect: `{ sessionId, feature, phase, hooks, events }`.
|
|
113
|
-
* `sessionId`/`feature`/`phase` come from the synthetic 'session' header
|
|
114
|
-
* line `resetActivity()` writes, when present.
|
|
115
|
-
*
|
|
116
|
-
* @param {string} [projectPath]
|
|
117
|
-
* @returns {{ sessionId: string, feature: string, phase: string, hooks: Array, events: Array }|null}
|
|
118
|
-
*/
|
|
66
|
+
/** @returns {{ sessionId: string, feature: string, phase: string, hooks: Array, events: Array }|null} */
|
|
119
67
|
export function readActivity(projectPath) {
|
|
120
68
|
const lines = readLines(projectPath);
|
|
121
69
|
if (lines.length === 0) return null;
|
|
@@ -132,17 +80,8 @@ export function readActivity(projectPath) {
|
|
|
132
80
|
};
|
|
133
81
|
}
|
|
134
82
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
* that is NOT append-only (a session boundary, not a log entry). Truncates
|
|
138
|
-
* the file and writes a 'session' header line so `readActivity()` can
|
|
139
|
-
* recover sessionId/feature/phase.
|
|
140
|
-
*
|
|
141
|
-
* @param {string} sessionId - ISO timestamp of new session
|
|
142
|
-
* @param {string} feature - Active feature name (empty string if none)
|
|
143
|
-
* @param {string} phase - Current phase (empty string if none)
|
|
144
|
-
* @param {string} [projectPath]
|
|
145
|
-
*/
|
|
83
|
+
// The one write that truncates: the 'session' header is what `readActivity()`
|
|
84
|
+
// recovers sessionId/feature/phase from.
|
|
146
85
|
export function resetActivity(sessionId, feature, phase, projectPath) {
|
|
147
86
|
try {
|
|
148
87
|
ensureLogsDir(projectPath);
|
|
@@ -154,18 +93,9 @@ export function resetActivity(sessionId, feature, phase, projectPath) {
|
|
|
154
93
|
};
|
|
155
94
|
writeFileSync(getActivityPath(projectPath), JSON.stringify(session) + '\n', 'utf-8');
|
|
156
95
|
} catch {
|
|
157
|
-
// Fail-silent
|
|
158
96
|
}
|
|
159
97
|
}
|
|
160
98
|
|
|
161
|
-
/**
|
|
162
|
-
* Archive the current activity log to sessions/ for historical logging.
|
|
163
|
-
* Only archives when there is at least one hook or event line — a bare (or
|
|
164
|
-
* absent) log is not worth preserving. `renameSync` keeps this O(1) even at
|
|
165
|
-
* the multi-MB sizes rotation is meant to catch (no read+rewrite of content).
|
|
166
|
-
*
|
|
167
|
-
* @param {string} [projectPath] - Project root (defaults to cwd)
|
|
168
|
-
*/
|
|
169
99
|
export function archiveSession(projectPath) {
|
|
170
100
|
try {
|
|
171
101
|
const activityPath = getActivityPath(projectPath);
|
|
@@ -183,20 +113,10 @@ export function archiveSession(projectPath) {
|
|
|
183
113
|
const archivePath = join(sessionsDir, `${stamp}.jsonl`);
|
|
184
114
|
renameSync(activityPath, archivePath);
|
|
185
115
|
} catch {
|
|
186
|
-
// Fail-silent
|
|
187
116
|
}
|
|
188
117
|
}
|
|
189
118
|
|
|
190
|
-
/**
|
|
191
|
-
* Rotate the activity log when it crosses `thresholdBytes` — the real call
|
|
192
|
-
* site for archiveSession() (previously written but never invoked anywhere).
|
|
193
|
-
* Called from worktree-context.js on SessionStart, the only reliable
|
|
194
|
-
* "between sessions" checkpoint the harness gets.
|
|
195
|
-
*
|
|
196
|
-
* @param {string} [projectPath] - Project root (defaults to cwd)
|
|
197
|
-
* @param {number} [thresholdBytes] - Rotate once the log is at least this large
|
|
198
|
-
* @returns {boolean} true if a rotation was performed
|
|
199
|
-
*/
|
|
119
|
+
/** @returns {boolean} true if a rotation was performed */
|
|
200
120
|
export function rotateActivityLogIfLarge(projectPath, thresholdBytes = ROTATE_THRESHOLD_BYTES) {
|
|
201
121
|
try {
|
|
202
122
|
const activityPath = getActivityPath(projectPath);
|
|
@@ -1,15 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* O hook de SessionStart tem 5 s de timeout e o reaper mede git por árvore: por isso o
|
|
5
|
-
* disparo só olha o disco (nenhum subprocesso) e solta este arquivo como worker DESTACADO
|
|
6
|
-
* (`--run <raiz>`), que chama o CLI. Só a raiz: o worker nasce com `cwd` no git common dir
|
|
7
|
-
* e nunca segura o diretório de uma worktree.
|
|
8
|
-
*
|
|
9
|
-
* Fail-open em tudo. `MORPH_WORKTREE_REAP=0` desliga.
|
|
10
|
-
*
|
|
11
|
-
* @module agent-reap
|
|
12
|
-
*/
|
|
1
|
+
// O SessionStart tem 5 s: o disparo só olha o disco e solta um worker DESTACADO
|
|
2
|
+
// com `cwd` no git common dir, que nunca segura o diretório de uma worktree.
|
|
13
3
|
|
|
14
4
|
import { spawn } from 'child_process';
|
|
15
5
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
|
|
@@ -29,7 +19,6 @@ function anyEntry(dir, pattern) {
|
|
|
29
19
|
try { return readdirSync(dir).some((name) => pattern.test(name)); } catch { return false; }
|
|
30
20
|
}
|
|
31
21
|
|
|
32
|
-
/** Uma worktree de agent registrada, ou uma branch `morph-task/agent-*` (solta ou empacotada). */
|
|
33
22
|
export function hasAgentLeftovers(common) {
|
|
34
23
|
if (anyEntry(join(common, 'worktrees'), ADMIN_DIR)) return true;
|
|
35
24
|
if (anyEntry(join(common, 'refs', 'heads', 'morph-task'), AGENT_BRANCH_FILE)) return true;
|
|
@@ -49,10 +38,6 @@ function recentlyKicked(marker, now) {
|
|
|
49
38
|
}
|
|
50
39
|
}
|
|
51
40
|
|
|
52
|
-
/**
|
|
53
|
-
* @param {string} dir - diretório do projeto da sessão
|
|
54
|
-
* @returns {{ kicked: boolean, reason?: string, pid?: number }}
|
|
55
|
-
*/
|
|
56
41
|
export function kickAgentReap(dir, { spawnFn = spawn, env = process.env, now = Date.now } = {}) {
|
|
57
42
|
if (env.MORPH_WORKTREE_REAP === '0') return { kicked: false, reason: 'disabled' };
|
|
58
43
|
const common = resolveGitCommonDir(dir);
|
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Build/Typecheck verification utility.
|
|
3
|
-
*
|
|
4
|
-
* Stack-aware: determines the correct build command(s) based on project stack.
|
|
5
|
-
* Used by the Stop hook and `morph-spec verify` to verify code health.
|
|
6
|
-
*
|
|
7
|
-
* Pure functions — no I/O except the actual build execution.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
1
|
import { execSync } from 'child_process';
|
|
11
2
|
import { existsSync } from 'fs';
|
|
12
3
|
import { join } from 'path';
|
|
@@ -22,25 +13,8 @@ function isFrontendStack(s) {
|
|
|
22
13
|
return s.includes('nextjs') || s.includes('node') || s.includes('react') || s.includes('vue');
|
|
23
14
|
}
|
|
24
15
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*
|
|
28
|
-
* Kept for backward compatibility with single-stack projects and existing
|
|
29
|
-
* callers. A monorepo stack that mixes a frontend token with "dotnet" (e.g.
|
|
30
|
-
* the detector's own `${frontendStack}+dotnet` format, or a hyphenated
|
|
31
|
-
* `dotnet-nextjs`) should resolve through `getBuildSteps()` instead: this
|
|
32
|
-
* function alone can only ever return ONE command, and — because it checks
|
|
33
|
-
* `dotnet` before the frontend tokens — a compound stack always resolves to
|
|
34
|
-
* `dotnet build` even when the frontend is what actually needs checking (or
|
|
35
|
-
* when there's no root-level `.sln`/`.csproj`, e.g. in a monorepo where the
|
|
36
|
-
* backend lives in a subfolder). `getBuildSteps()` runs BOTH sides, each
|
|
37
|
-
* from its own working directory, instead of one command winning by
|
|
38
|
-
* substring match order.
|
|
39
|
-
*
|
|
40
|
-
* @param {string} stack - Project stack (e.g., 'dotnet-nextjs', 'nextjs')
|
|
41
|
-
* @param {boolean} [hasTsConfig] - Whether tsconfig.json exists (for TS projects)
|
|
42
|
-
* @returns {string|null} Build command or null if no check available
|
|
43
|
-
*/
|
|
16
|
+
// Single stack only: a compound stack resolves to `dotnet build` alone. Use
|
|
17
|
+
// `getBuildSteps()` for those.
|
|
44
18
|
export function getBuildCommand(stack, hasTsConfig = false) {
|
|
45
19
|
if (!stack) return null;
|
|
46
20
|
const s = stack.toLowerCase();
|
|
@@ -56,26 +30,8 @@ export function getBuildCommand(stack, hasTsConfig = false) {
|
|
|
56
30
|
return null;
|
|
57
31
|
}
|
|
58
32
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
*
|
|
62
|
-
* A monorepo stack mixes a frontend token and "dotnet" (see
|
|
63
|
-
* `detectProject()`'s own `${frontendStack}+dotnet` format) — in that case
|
|
64
|
-
* BOTH sides are checked, each from its own working directory
|
|
65
|
-
* (`frontendPath` / `backendPath`, as stored in
|
|
66
|
-
* `.morph/config/config.json → project`), instead of one command silently
|
|
67
|
-
* winning — or running from the wrong cwd — by substring match order.
|
|
68
|
-
* Single-stack projects (no `frontendPath`/`backendPath`, or a stack that
|
|
69
|
-
* only matches one side) resolve to exactly one step, from `cwd`, matching
|
|
70
|
-
* `getBuildCommand()`'s existing single-stack behavior.
|
|
71
|
-
*
|
|
72
|
-
* @param {string} stack
|
|
73
|
-
* `backendTarget` names the solution/project to build (relative to the backend
|
|
74
|
-
* directory) when that directory holds none itself.
|
|
75
|
-
*
|
|
76
|
-
* @param {{ cwd: string, frontendPath?: string, backendPath?: string, backendTarget?: string }} opts
|
|
77
|
-
* @returns {Array<{ command: string, cwd: string }>}
|
|
78
|
-
*/
|
|
33
|
+
// A compound stack checks BOTH sides, each from its own directory.
|
|
34
|
+
// `backendTarget` names the solution when the backend directory holds none.
|
|
79
35
|
export function getBuildSteps(stack, { cwd, frontendPath, backendPath, backendTarget } = {}) {
|
|
80
36
|
if (!stack || !cwd) return [];
|
|
81
37
|
const s = stack.toLowerCase();
|
|
@@ -88,10 +44,7 @@ export function getBuildSteps(stack, { cwd, frontendPath, backendPath, backendTa
|
|
|
88
44
|
|
|
89
45
|
if (isFrontendStack(s)) {
|
|
90
46
|
const frontendDir = frontendPath ? join(cwd, frontendPath) : cwd;
|
|
91
|
-
// node_modules
|
|
92
|
-
// `morph-spec worktree link` has tsconfig.json (versioned) but no toolchain — running
|
|
93
|
-
// `npx tsc` there fetches an unrelated "tsc" package and fails noisily. No toolchain →
|
|
94
|
-
// no tsc step; the real typecheck runs wherever node_modules exists (root/CI).
|
|
47
|
+
// Without node_modules, `npx tsc` fetches an unrelated "tsc" package.
|
|
95
48
|
if (
|
|
96
49
|
existsSync(join(frontendDir, 'tsconfig.json')) &&
|
|
97
50
|
existsSync(join(frontendDir, 'node_modules'))
|
|
@@ -103,39 +56,17 @@ export function getBuildSteps(stack, { cwd, frontendPath, backendPath, backendTa
|
|
|
103
56
|
return steps;
|
|
104
57
|
}
|
|
105
58
|
|
|
106
|
-
/**
|
|
107
|
-
* Parse build command result into a structured object.
|
|
108
|
-
*
|
|
109
|
-
* @param {number} exitCode - Process exit code (0 = success)
|
|
110
|
-
* @param {string} output - stdout+stderr from build
|
|
111
|
-
* @returns {{ status: 'pass'|'fail', output: string }}
|
|
112
|
-
*/
|
|
113
59
|
export function parseBuildResult(exitCode, output) {
|
|
114
60
|
const trimmed = (output || '').trim();
|
|
115
61
|
if (exitCode === 0) {
|
|
116
62
|
return { status: 'pass', output: '' };
|
|
117
63
|
}
|
|
118
|
-
// Truncate to last 20 lines
|
|
119
64
|
const lines = trimmed.split('\n');
|
|
120
65
|
const truncated = lines.slice(-20).join('\n');
|
|
121
66
|
return { status: 'fail', output: truncated };
|
|
122
67
|
}
|
|
123
68
|
|
|
124
|
-
/**
|
|
125
|
-
* Run build check for a project — monorepo-aware (see `getBuildSteps()`).
|
|
126
|
-
*
|
|
127
|
-
* For a compound stack (e.g. "nextjs+dotnet"), runs each resolved step in
|
|
128
|
-
* its own working directory and fails if any step fails; output from all
|
|
129
|
-
* failing steps is concatenated (prefixed by the command that produced it)
|
|
130
|
-
* so a frontend typecheck failure and a backend build failure are never
|
|
131
|
-
* silently masked by one another.
|
|
132
|
-
*
|
|
133
|
-
* @param {string} cwd - Project root directory
|
|
134
|
-
* @param {string} stack - Project stack string
|
|
135
|
-
* @param {number} [timeout=30000] - Timeout in ms
|
|
136
|
-
* @param {{ frontendPath?: string, backendPath?: string, backendTarget?: string }} [paths] - Monorepo sub-paths, from config.json → project
|
|
137
|
-
* @returns {{ status: 'pass'|'fail'|'skip', output: string }}
|
|
138
|
-
*/
|
|
69
|
+
/** @returns {{ status: 'pass'|'fail'|'skip', output: string }} */
|
|
139
70
|
export function runBuildCheck(cwd, stack, timeout = 30000, paths = {}) {
|
|
140
71
|
const steps = getBuildSteps(stack, { cwd, frontendPath: paths.frontendPath, backendPath: paths.backendPath, backendTarget: paths.backendTarget });
|
|
141
72
|
if (steps.length === 0) return { status: 'skip', output: '' };
|
|
@@ -1,44 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Two Claude Code sessions worked the SAME feature at the same time without
|
|
5
|
-
* seeing each other: one finished it and removed the worktree out from under
|
|
6
|
-
* the other's running sub-agent. A complete recap.md was discarded, a sub-agent
|
|
7
|
-
* wasted, and a decision taken on state that was already obsolete. There was no
|
|
8
|
-
* lock, no claim, no heartbeat — not even a warning.
|
|
9
|
-
*
|
|
10
|
-
* DELIBERATELY ADVISORY. This is not a lock: nothing here ever blocks a
|
|
11
|
-
* command. The framework is fail-open by principle, and a strong lock left
|
|
12
|
-
* behind by a crashed session would strand a feature for no gain. Being
|
|
13
|
-
* VISIBLE is the whole fix.
|
|
14
|
-
*
|
|
15
|
-
* Location: `<git-common-dir>/morph-spec/claims.json`, beside the fleet's
|
|
16
|
-
* env-registry, for the same three reasons — it can never dirty the `git
|
|
17
|
-
* status` the finish gates read, it survives `git clean -fdx`, and the COMMON
|
|
18
|
-
* dir is the one place every linked worktree of the repo shares. That last one
|
|
19
|
-
* is decisive here: in the observed incident one session sat inside the
|
|
20
|
-
* worktree and the other in the primary root, so a claim written to the
|
|
21
|
-
* branch-local `feature.json` would have been invisible to exactly the session
|
|
22
|
-
* that needed to read it.
|
|
23
|
-
*
|
|
24
|
-
* Writes are tmp+rename (torn file impossible); a corrupt registry reads as
|
|
25
|
-
* empty — the cost is a lost warning, never a crash.
|
|
26
|
-
*/
|
|
1
|
+
// ADVISORY, never a lock: a lock left by a crashed session would strand a feature.
|
|
2
|
+
// Lives in the git COMMON dir, the one place every worktree of the repo shares.
|
|
27
3
|
|
|
28
4
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
29
5
|
import { dirname, join } from 'path';
|
|
30
6
|
|
|
31
|
-
/**
|
|
32
|
-
* A claim with no heartbeat for this long is considered dead. The heartbeat
|
|
33
|
-
* fires on tool activity, so this is really "how long a session may sit idle
|
|
34
|
-
* before its claim stops warning others".
|
|
35
|
-
*/
|
|
36
7
|
export const CLAIM_TTL_MS = 30 * 60 * 1000;
|
|
37
8
|
|
|
38
|
-
/** Minimum gap between heartbeat WRITES. The heartbeat runs on every tool call. */
|
|
39
9
|
export const HEARTBEAT_THROTTLE_MS = 60 * 1000;
|
|
40
10
|
|
|
41
|
-
/** Claim file path for a repo's common git dir. */
|
|
42
11
|
export function claimsPath(gitCommonDir) {
|
|
43
12
|
return join(gitCommonDir, 'morph-spec', 'claims.json');
|
|
44
13
|
}
|
|
@@ -60,23 +29,13 @@ function writeRegistry(gitCommonDir, registry) {
|
|
|
60
29
|
renameSync(tmp, path);
|
|
61
30
|
}
|
|
62
31
|
|
|
63
|
-
/** Is this claim still within its TTL? A missing/unparseable heartbeat is dead. */
|
|
64
32
|
export function isLive(claim, now = Date.now()) {
|
|
65
33
|
const beat = Date.parse(claim?.heartbeatAt ?? '');
|
|
66
34
|
return Number.isFinite(beat) && now - beat < CLAIM_TTL_MS;
|
|
67
35
|
}
|
|
68
36
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*
|
|
72
|
-
* A claim held by ANOTHER session is TAKEN OVER, not refused — advisory by
|
|
73
|
-
* design, and the caller has already been warned via `foreignClaim`. The
|
|
74
|
-
* takeover is recorded in `previousSessionId` so it is never silent.
|
|
75
|
-
*
|
|
76
|
-
* @param {string} feature
|
|
77
|
-
* @param {{ gitCommonDir: string, sessionId: string, pid?: number, path?: string }} opts
|
|
78
|
-
* @returns {object} the stored claim
|
|
79
|
-
*/
|
|
37
|
+
// Another session's claim is TAKEN OVER, not refused; `previousSessionId` keeps
|
|
38
|
+
// the takeover visible.
|
|
80
39
|
export function acquireClaim(feature, { gitCommonDir, sessionId, pid = process.pid, path = null }) {
|
|
81
40
|
const registry = readRegistry(gitCommonDir);
|
|
82
41
|
const previous = registry.claims[feature];
|
|
@@ -95,17 +54,7 @@ export function acquireClaim(feature, { gitCommonDir, sessionId, pid = process.p
|
|
|
95
54
|
return claim;
|
|
96
55
|
}
|
|
97
56
|
|
|
98
|
-
|
|
99
|
-
* Refresh our own claim's heartbeat. Throttled — this runs on every tool call,
|
|
100
|
-
* and a write per call would be pure I/O waste.
|
|
101
|
-
*
|
|
102
|
-
* Never touches a claim owned by another session: a heartbeat is proof of life,
|
|
103
|
-
* and forging someone else's would defeat the TTL.
|
|
104
|
-
*
|
|
105
|
-
* @param {string} feature
|
|
106
|
-
* @param {{ gitCommonDir: string, sessionId: string }} opts
|
|
107
|
-
* @returns {boolean} true when a write actually happened
|
|
108
|
-
*/
|
|
57
|
+
// Never another session's claim: forging its heartbeat would defeat the TTL.
|
|
109
58
|
export function heartbeatClaim(feature, { gitCommonDir, sessionId }) {
|
|
110
59
|
const registry = readRegistry(gitCommonDir);
|
|
111
60
|
const claim = registry.claims[feature];
|
|
@@ -119,45 +68,13 @@ export function heartbeatClaim(feature, { gitCommonDir, sessionId }) {
|
|
|
119
68
|
return true;
|
|
120
69
|
}
|
|
121
70
|
|
|
122
|
-
/** Local, git-independent heartbeat pre-check marker — see `shouldHeartbeat`. */
|
|
123
71
|
function heartbeatMarkPath(cwd) {
|
|
124
72
|
return join(cwd, '.morph', 'logs', '.heartbeat-check.json');
|
|
125
73
|
}
|
|
126
74
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
* but that registry lives under the git COMMON dir — resolving it (even via
|
|
131
|
-
* the pure-FS `git-paths.js`, never mind the old `execSync('git rev-parse
|
|
132
|
-
* --git-common-dir')`) is work `telemetry-log.js` pays on every tool call.
|
|
133
|
-
* This answers "is it due" from a tiny marker colocated with the CALLER's own
|
|
134
|
-
* `.morph/logs/` (already known, no git lookup of any kind) so that on ~99%
|
|
135
|
-
* of calls NOTHING git-related happens at all — not the subprocess, not even
|
|
136
|
-
* the FS walk.
|
|
137
|
-
*
|
|
138
|
-
* Side effect: when it returns `true` (heartbeat IS due), it immediately
|
|
139
|
-
* stamps the marker — mirroring `heartbeatClaim`'s own check-and-write
|
|
140
|
-
* pattern — so the NEXT call within the throttle window short-circuits here
|
|
141
|
-
* even if the caller's subsequent git resolution then fails (not actually a
|
|
142
|
-
* git repo, say): a failed attempt should not be retried on every single tool
|
|
143
|
-
* call either.
|
|
144
|
-
*
|
|
145
|
-
* This is a SEPARATE, LOCAL throttle from `claims.json`'s own authoritative
|
|
146
|
-
* `heartbeatAt` (keyed by git-common-dir, enforced inside `heartbeatClaim`) —
|
|
147
|
-
* the two can drift by up to one throttle window with no consequence: a
|
|
148
|
-
* missed local pre-check only costs a claim heartbeat that gets written on
|
|
149
|
-
* the very next call, and claims are advisory to begin with.
|
|
150
|
-
*
|
|
151
|
-
* Fail-open: any read/write error is treated as "due" — a broken marker must
|
|
152
|
-
* never permanently suppress the actual (authoritative) heartbeat. Not
|
|
153
|
-
* tmp+rename like `claims.json` — this file is low-stakes (worst case from a
|
|
154
|
-
* torn write is corrupt JSON, which just reads back as "due" next time) and
|
|
155
|
-
* shared by nobody outside this one project tree.
|
|
156
|
-
*
|
|
157
|
-
* @param {string} feature
|
|
158
|
-
* @param {{ cwd: string, sessionId: string, now?: number }} opts
|
|
159
|
-
* @returns {boolean}
|
|
160
|
-
*/
|
|
75
|
+
// Local pre-check so most tool calls skip resolving the git common dir. Stamps
|
|
76
|
+
// the marker when due, so a failed attempt is not retried on every call; an
|
|
77
|
+
// unreadable marker counts as due.
|
|
161
78
|
export function shouldHeartbeat(feature, { cwd, sessionId, now = Date.now() }) {
|
|
162
79
|
const markPath = heartbeatMarkPath(cwd);
|
|
163
80
|
try {
|
|
@@ -167,27 +84,16 @@ export function shouldHeartbeat(feature, { cwd, sessionId, now = Date.now() }) {
|
|
|
167
84
|
if (Number.isFinite(at) && now - at < HEARTBEAT_THROTTLE_MS) return false;
|
|
168
85
|
}
|
|
169
86
|
} catch {
|
|
170
|
-
// missing/corrupt marker — treat as due
|
|
171
87
|
}
|
|
172
88
|
|
|
173
89
|
try {
|
|
174
90
|
mkdirSync(dirname(markPath), { recursive: true });
|
|
175
91
|
writeFileSync(markPath, JSON.stringify({ feature, sessionId, at: new Date(now).toISOString() }));
|
|
176
92
|
} catch {
|
|
177
|
-
// best-effort — a failed stamp only costs a repeated attempt next call
|
|
178
93
|
}
|
|
179
94
|
return true;
|
|
180
95
|
}
|
|
181
96
|
|
|
182
|
-
/**
|
|
183
|
-
* The LIVE claim on this feature held by a session that is not us — the thing
|
|
184
|
-
* worth warning about. Returns null when the feature is unclaimed, claimed by
|
|
185
|
-
* us, or claimed by a session whose heartbeat went stale.
|
|
186
|
-
*
|
|
187
|
-
* @param {string} feature
|
|
188
|
-
* @param {{ gitCommonDir: string, sessionId?: string }} opts
|
|
189
|
-
* @returns {object|null}
|
|
190
|
-
*/
|
|
191
97
|
export function foreignClaim(feature, { gitCommonDir, sessionId }) {
|
|
192
98
|
const claim = readRegistry(gitCommonDir).claims[feature];
|
|
193
99
|
if (!claim) return null;
|
|
@@ -195,14 +101,6 @@ export function foreignClaim(feature, { gitCommonDir, sessionId }) {
|
|
|
195
101
|
return isLive(claim) ? claim : null;
|
|
196
102
|
}
|
|
197
103
|
|
|
198
|
-
/**
|
|
199
|
-
* Drop a feature's claim. Called by the manual `morph-spec claim <f> --release`
|
|
200
|
-
* (recovering from a session that died without releasing) and by `finish`.
|
|
201
|
-
*
|
|
202
|
-
* @param {string} feature
|
|
203
|
-
* @param {{ gitCommonDir: string }} opts
|
|
204
|
-
* @returns {boolean} false when there was nothing to release
|
|
205
|
-
*/
|
|
206
104
|
export function releaseClaim(feature, { gitCommonDir }) {
|
|
207
105
|
const registry = readRegistry(gitCommonDir);
|
|
208
106
|
if (!registry.claims[feature]) return false;
|
|
@@ -211,14 +109,7 @@ export function releaseClaim(feature, { gitCommonDir }) {
|
|
|
211
109
|
return true;
|
|
212
110
|
}
|
|
213
111
|
|
|
214
|
-
|
|
215
|
-
* Every claim on record, live and stale, newest heartbeat first. `live` is
|
|
216
|
-
* computed, never stored — a stored flag would go wrong the moment nobody
|
|
217
|
-
* rewrites the file.
|
|
218
|
-
*
|
|
219
|
-
* @param {string} gitCommonDir
|
|
220
|
-
* @returns {object[]}
|
|
221
|
-
*/
|
|
112
|
+
// `live` is computed, never stored: a stored flag goes wrong once nobody rewrites the file.
|
|
222
113
|
export function listClaims(gitCommonDir) {
|
|
223
114
|
const now = Date.now();
|
|
224
115
|
return Object.values(readRegistry(gitCommonDir).claims)
|
|
@@ -226,7 +117,6 @@ export function listClaims(gitCommonDir) {
|
|
|
226
117
|
.sort((a, b) => String(b.heartbeatAt).localeCompare(String(a.heartbeatAt)));
|
|
227
118
|
}
|
|
228
119
|
|
|
229
|
-
/** Human-readable age of a claim's last heartbeat ("3 min", "2 h", "—"). */
|
|
230
120
|
export function claimAge(claim, now = Date.now()) {
|
|
231
121
|
const beat = Date.parse(claim?.heartbeatAt ?? '');
|
|
232
122
|
if (!Number.isFinite(beat)) return '—';
|