@polymorphism-tech/morph-spec 8.15.1 → 8.17.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.
Files changed (53) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +19 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +10 -4
  7. package/framework/MORPH.md +4 -4
  8. package/framework/evals/rubrics/condensed.md +49 -0
  9. package/framework/hooks/README.md +3 -3
  10. package/framework/hooks/claude-code/core/chain.js +123 -0
  11. package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
  12. package/framework/hooks/claude-code/core/gate-guard.js +21 -25
  13. package/framework/hooks/claude-code/core/loop-detect.js +70 -64
  14. package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
  15. package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
  16. package/framework/hooks/claude-code/core/protect-spec-files.js +119 -103
  17. package/framework/hooks/claude-code/core/state-sync.js +32 -40
  18. package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
  19. package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
  20. package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
  21. package/framework/hooks/claude-code/core/validate-completion.js +147 -107
  22. package/framework/hooks/claude-code/core/worktree-context.js +25 -22
  23. package/framework/hooks/claude-code/statusline.py +26 -4
  24. package/framework/hooks/shared/activity-logger.js +120 -69
  25. package/framework/hooks/shared/claim-registry.js +61 -1
  26. package/framework/hooks/shared/git-paths.js +94 -0
  27. package/framework/hooks/shared/hook-response.js +67 -0
  28. package/framework/hooks/shared/state-reader.js +95 -29
  29. package/framework/hooks/shared/state-writer.js +16 -0
  30. package/framework/rules/morph-task-tracking.md +4 -2
  31. package/framework/schemas/tasks.schema.json +10 -0
  32. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  33. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  34. package/framework/skills/core/morph-apply/SKILL.md +40 -28
  35. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  36. package/framework/skills/core/morph-eval/SKILL.md +16 -13
  37. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  38. package/framework/skills/core/morph-implement/SKILL.md +26 -25
  39. package/framework/skills/core/morph-plan/SKILL.md +14 -3
  40. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  41. package/framework/skills/core/morph-review/SKILL.md +1 -1
  42. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  43. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  44. package/package.json +2 -2
  45. package/src/commands/observability/cost.js +137 -0
  46. package/src/commands/project/standards.js +220 -0
  47. package/src/commands/state/approve.js +30 -9
  48. package/src/dashboard/scanner/hooks-scanner.js +11 -2
  49. package/src/lib/installers/memory-installer.js +1 -1
  50. package/src/lib/tasks/tasks-json-parser.js +26 -0
  51. package/src/lib/telemetry/telemetry-reader.js +213 -0
  52. package/src/utils/hooks-installer.js +84 -79
  53. package/framework/hooks/shared/session-snapshot.js +0 -105
@@ -1,28 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Stop Hook: Phase Transition Alert + Build Check + Session Snapshot
4
+ * Stop Hook: Phase Transition Alert + Build Check
5
5
  *
6
6
  * Event: Stop
7
7
  *
8
8
  * 1. Detects transitions into the `implement` phase and recommends /compact.
9
- * 2. Runs stack-aware build check — warns only on failure (silent on pass).
10
- * 3. Saves session snapshot for next-session orientation.
9
+ * 2. Runs stack-aware build check — dirty-flag gated (R1.2), warns only on
10
+ * failure (silent on pass/skip).
11
11
  *
12
12
  * Phase transition tracking: stores last-seen phase per feature in
13
13
  * .morph/memory/phase-watch.json so the alert fires exactly once.
14
14
  *
15
+ * Build-dirty gate: post-edit-typecheck.js touches
16
+ * .morph/memory/build-dirty.json on every source-file Write/Edit. Without
17
+ * that flag nothing changed since the last verified build, so the (often
18
+ * expensive) full build/typecheck is skipped entirely instead of running on
19
+ * every single Stop.
20
+ *
15
21
  * Fail-open: exits 0 on any error.
16
22
  */
17
23
 
18
- import { readFileSync, writeFileSync, existsSync } from 'fs';
24
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
19
25
  import { join } from 'path';
26
+ import { createHash } from 'crypto';
20
27
  import { runBuildCheck } from '../../shared/build-check.js';
21
- import { saveSessionSnapshot } from '../../shared/session-snapshot.js';
22
28
  import {
23
29
  stateExists, getActiveFeature, getMostRecentFeature, derivePhaseForFeature,
24
30
  } from '../../shared/state-reader.js';
25
- import { injectContext, pass } from '../../shared/hook-response.js';
31
+ import { contextResult, runStandalone } from '../../shared/hook-response.js';
26
32
  import { logHookActivity, logEvent } from '../../shared/activity-logger.js';
27
33
 
28
34
  /**
@@ -121,122 +127,156 @@ const COMPACT_TRIGGER_PHASES = new Set(['implement']);
121
127
  // Phases where compaction is suggested when arriving (heavy spec phases)
122
128
  const HEAVY_SOURCE_PHASES = new Set(['plan', 'uiux']);
123
129
 
124
- try {
125
- // Prevent infinite loop
126
- if (process.env.MORPH_STOP_HOOK_ACTIVE === '1') pass();
130
+ /**
131
+ * @param {object} _payload - parsed Stop stdin payload (unused — this hook
132
+ * reads its own state from disk, not from the tool payload)
133
+ * @returns {Promise<object|null>} a hookSpecificOutput advisory (phase
134
+ * transition / build failure / recap drift), or null (pass/silent).
135
+ */
136
+ export async function run(_payload) {
137
+ try {
138
+ // Prevent infinite loop
139
+ if (process.env.MORPH_STOP_HOOK_ACTIVE === '1') return null;
127
140
 
128
- if (!stateExists()) pass();
141
+ if (!stateExists()) return null;
129
142
 
130
- // getActiveFeature() only returns in_progress/draft features.
131
- // Fall back to the most recently updated feature when all features are 'done'.
132
- let active = getActiveFeature() || getMostRecentFeature();
133
- if (!active) pass();
143
+ // getActiveFeature() only returns in_progress/draft features.
144
+ // Fall back to the most recently updated feature when all features are 'done'.
145
+ let active = getActiveFeature() || getMostRecentFeature();
146
+ if (!active) return null;
134
147
 
135
- const { name, feature } = active;
136
- const cwd = process.cwd();
137
- const warnings = [];
148
+ const { name, feature } = active;
149
+ const cwd = process.cwd();
150
+ const warnings = [];
138
151
 
139
- // ── Phase transition detection ────────────────────────────────────────────
140
- const currentPhase = feature.phase || derivePhaseForFeature(name, cwd);
152
+ // .morph/memory/ hosts phase-watch.json, build-dirty.json (written by
153
+ // post-edit-typecheck.js) and warned-hashes.json — ensure it exists once,
154
+ // up front, so every write below can assume the dir is there.
155
+ const memDir = join(cwd, '.morph', 'memory');
156
+ try { mkdirSync(memDir, { recursive: true }); } catch { /* non-blocking */ }
141
157
 
142
- const watchPath = join(cwd, '.morph', 'memory', 'phase-watch.json');
143
- let phaseWatch = {};
144
- try {
145
- if (existsSync(watchPath)) phaseWatch = JSON.parse(readFileSync(watchPath, 'utf-8'));
146
- } catch { /* corrupt file — start fresh */ }
158
+ // ── Phase transition detection ────────────────────────────────────────────
159
+ const currentPhase = feature.phase || derivePhaseForFeature(name, cwd);
147
160
 
148
- const lastPhase = phaseWatch[name];
161
+ const watchPath = join(memDir, 'phase-watch.json');
162
+ let phaseWatch = {};
163
+ try {
164
+ if (existsSync(watchPath)) phaseWatch = JSON.parse(readFileSync(watchPath, 'utf-8'));
165
+ } catch { /* corrupt file — start fresh */ }
149
166
 
150
- // Update stored phase (always, so the alert only fires once per transition)
151
- try {
152
- phaseWatch[name] = currentPhase;
153
- writeFileSync(watchPath, JSON.stringify(phaseWatch, null, 2), 'utf-8');
154
- } catch { /* non-blocking */ }
155
-
156
- // Alert when entering implement from a heavy spec phase
157
- const isPhaseTransition = lastPhase && lastPhase !== currentPhase;
158
- const enteringImplement = COMPACT_TRIGGER_PHASES.has(currentPhase);
159
- const comingFromHeavyPhase = HEAVY_SOURCE_PHASES.has(lastPhase);
160
-
161
- if (isPhaseTransition && enteringImplement && comingFromHeavyPhase) {
162
- warnings.push(
163
- `⚡ Phase advanced: ${lastPhase} → implement`,
164
- ``,
165
- `💡 Recommended before starting implementation:`,
166
- ` Run /compact to free up context — spec.md, mandate.md and tasks.json`,
167
- ` loaded during planning take significant space. A compact now gives`,
168
- ` Claude a clean slate focused on writing code.`,
169
- ``,
170
- ` After /compact, resume with: /morph-apply ${name}`,
171
- );
172
- }
167
+ const lastPhase = phaseWatch[name];
173
168
 
174
- // ── Build/typecheck verification ──────────────────────────────────────────
175
- // Stack-aware build check — advisory warning if build fails.
176
- // Success is silent (no output on pass).
177
- try {
178
- const configPath = join(cwd, '.morph', 'config', 'config.json');
179
- if (existsSync(configPath)) {
180
- const config = JSON.parse(readFileSync(configPath, 'utf-8'));
181
- const stack = config?.project?.stack;
182
- const { frontendPath, backendPath } = config?.project ?? {};
183
- if (stack && currentPhase === 'implement') {
184
- const buildResult = runBuildCheck(cwd, stack, undefined, { frontendPath, backendPath });
185
- if (buildResult.status === 'fail') {
186
- warnings.push('⚠ Build/typecheck failed:');
187
- warnings.push(buildResult.output);
188
- warnings.push('Fix build errors before ending session.');
169
+ // Update stored phase (always, so the alert only fires once per transition)
170
+ try {
171
+ phaseWatch[name] = currentPhase;
172
+ writeFileSync(watchPath, JSON.stringify(phaseWatch, null, 2), 'utf-8');
173
+ } catch { /* non-blocking */ }
174
+
175
+ // Alert when entering implement from a heavy spec phase
176
+ const isPhaseTransition = lastPhase && lastPhase !== currentPhase;
177
+ const enteringImplement = COMPACT_TRIGGER_PHASES.has(currentPhase);
178
+ const comingFromHeavyPhase = HEAVY_SOURCE_PHASES.has(lastPhase);
179
+
180
+ if (isPhaseTransition && enteringImplement && comingFromHeavyPhase) {
181
+ warnings.push(
182
+ `⚡ Phase advanced: ${lastPhase} → implement`,
183
+ ``,
184
+ `💡 Recommended before starting implementation:`,
185
+ ` Run /compact to free up context — spec.md, mandate.md and tasks.json`,
186
+ ` loaded during planning take significant space. A compact now gives`,
187
+ ` Claude a clean slate focused on writing code.`,
188
+ ``,
189
+ ` After /compact, resume with: /morph-apply ${name}`,
190
+ );
191
+ }
192
+
193
+ // ── Build/typecheck verification (dirty-flag gated — R1.2) ────────────────
194
+ // Only run the (often expensive) full build/typecheck when something
195
+ // actually changed since the last time it was verified — post-edit-
196
+ // typecheck.js sets the flag on every source-file Write/Edit. No flag →
197
+ // nothing to verify → skip silently, no config/stack read even attempted.
198
+ const BUILD_CHECK_TIMEOUT_MS = 30000; // per-step timeout — build-check.js's own default, made explicit here
199
+ try {
200
+ const dirtyFlagPath = join(memDir, 'build-dirty.json');
201
+ if (existsSync(dirtyFlagPath)) {
202
+ const configPath = join(cwd, '.morph', 'config', 'config.json');
203
+ if (existsSync(configPath)) {
204
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
205
+ const stack = config?.project?.stack;
206
+ const { frontendPath, backendPath } = config?.project ?? {};
207
+ if (stack && currentPhase === 'implement') {
208
+ const buildResult = runBuildCheck(cwd, stack, BUILD_CHECK_TIMEOUT_MS, { frontendPath, backendPath });
209
+ if (buildResult.status === 'fail') {
210
+ warnings.push('⚠ Build/typecheck failed:');
211
+ warnings.push(buildResult.output);
212
+ warnings.push('Fix build errors before ending session.');
213
+ // keep the dirty flag — still dirty until a build actually passes
214
+ } else {
215
+ // 'pass' → verified clean. 'skip' → no build step was even
216
+ // resolved for this stack (e.g. fresh worktree, no tsconfig) —
217
+ // nothing to verify, so there is nothing left to stay dirty about.
218
+ // Either way, re-arm on the NEXT source edit, not every Stop.
219
+ try { unlinkSync(dirtyFlagPath); } catch { /* non-blocking */ }
220
+ }
221
+ }
189
222
  }
190
- // pass and skip → silent (success is silent)
191
223
  }
224
+ // No flag → skip the build entirely, silently.
225
+ } catch {
226
+ // Fail-open — build check must never block Stop
192
227
  }
193
- } catch {
194
- // Fail-open — build check must never block Stop
195
- }
196
- // ────────────────────────────────────────────────────────────────────────
228
+ // ────────────────────────────────────────────────────────────────────────
197
229
 
198
- // ── Recap + tasks.json audit ──────────────────────────────────────────────
199
- // Surface drift between recap.md and tasks.json, plus missing standard
200
- // citations. Advisory warnings only — Stop hook never blocks.
201
- try {
202
- const featurePath = join(cwd, '.morph', 'features', name);
203
- const recapWarnings = auditRecapAndTasks(featurePath);
204
- if (recapWarnings.length > 0) {
205
- warnings.push('📋 Recap audit:');
206
- for (const w of recapWarnings) warnings.push(` • ${w}`);
207
- }
208
- } catch {
209
- // Fail-open — audit must never block Stop
210
- }
211
- // ────────────────────────────────────────────────────────────────────────
230
+ // ── Recap + tasks.json audit ──────────────────────────────────────────────
231
+ // Surface drift between recap.md and tasks.json, plus missing standard
232
+ // citations. Advisory warnings only — Stop hook never blocks.
233
+ //
234
+ // The same audit re-runs every Stop; without dedup it re-injects the same
235
+ // ~500-token block turn after turn even when nothing changed. Hash-gate it:
236
+ // only inject when the warning SET differs from the last one actually
237
+ // surfaced for this feature. Resolved (no more warnings) clears the stored
238
+ // hash, so a later recurrence — even an identical one — is reported fresh.
239
+ try {
240
+ const featurePath = join(cwd, '.morph', 'features', name);
241
+ const recapWarnings = auditRecapAndTasks(featurePath);
212
242
 
213
- // ── Session snapshot ──────────────────────────────────────────────────────
214
- // Save session state for next session's "getting up to speed" routine.
215
- try {
216
- if (active) {
217
- const { name } = active;
218
- saveSessionSnapshot({
219
- feature: name,
220
- phase: currentPhase,
221
- buildStatus: 'skip',
222
- lastEditedFiles: [],
223
- }, cwd);
243
+ const warnedHashesPath = join(memDir, 'warned-hashes.json');
244
+ let warnedHashes = {};
245
+ try {
246
+ if (existsSync(warnedHashesPath)) warnedHashes = JSON.parse(readFileSync(warnedHashesPath, 'utf-8'));
247
+ } catch { /* corrupt file — start fresh */ }
248
+
249
+ if (recapWarnings.length > 0) {
250
+ const hash = createHash('sha1').update(recapWarnings.join('\n')).digest('hex');
251
+ if (warnedHashes[name]?.recap !== hash) {
252
+ warnings.push('📋 Recap audit:');
253
+ for (const w of recapWarnings) warnings.push(` • ${w}`);
254
+ warnedHashes[name] = { ...warnedHashes[name], recap: hash };
255
+ try { writeFileSync(warnedHashesPath, JSON.stringify(warnedHashes, null, 2), 'utf-8'); } catch { /* non-blocking */ }
256
+ }
257
+ // same hash as last time it fired → already surfaced, stay silent
258
+ } else if (warnedHashes[name]?.recap) {
259
+ delete warnedHashes[name].recap;
260
+ try { writeFileSync(warnedHashesPath, JSON.stringify(warnedHashes, null, 2), 'utf-8'); } catch { /* non-blocking */ }
261
+ }
262
+ } catch {
263
+ // Fail-open — audit must never block Stop
224
264
  }
225
- } catch {
226
- // Fail-open — snapshot save must never block Stop
227
- }
228
- // ────────────────────────────────────────────────────────────────────────
265
+ // ────────────────────────────────────────────────────────────────────────
229
266
 
230
- if (warnings.length === 0) pass();
267
+ if (warnings.length === 0) return null;
231
268
 
232
- const message = [
233
- 'MORPH-SPEC:',
234
- ...warnings.map(w => ` ${w}`),
235
- ].join('\n');
269
+ const message = [
270
+ 'MORPH-SPEC:',
271
+ ...warnings.map(w => ` ${w}`),
272
+ ].join('\n');
236
273
 
237
- logHookActivity('validate-completion', 'Stop', `warnings(${warnings.length})`);
238
- logEvent('session_end', { feature: name, phase: currentPhase, warnings: warnings.length });
239
- injectContext(message, 'Stop');
240
- } catch {
241
- process.exit(0);
274
+ logHookActivity('validate-completion', 'Stop', `warnings(${warnings.length})`);
275
+ logEvent('session_end', { feature: name, phase: currentPhase, warnings: warnings.length });
276
+ return contextResult(message, 'Stop');
277
+ } catch {
278
+ return null;
279
+ }
242
280
  }
281
+
282
+ await runStandalone(run);
@@ -24,6 +24,11 @@
24
24
  * 2. Primary root → active-feature summary (phase, task stats, pending
25
25
  * gates), or silent if there's no feature in progress.
26
26
  *
27
+ * Also the real call site for activity-log rotation (R1.2 of the performance
28
+ * plan): SessionStart is the only reliable "between sessions" checkpoint the
29
+ * harness gets, so this is where `.morph/logs/activity.jsonl` gets archived
30
+ * to `.morph/logs/sessions/` once it crosses the size threshold.
31
+ *
27
32
  * Fail-open: any error, or nothing to report, → pass() silently. This hook
28
33
  * never blocks a session from starting.
29
34
  */
@@ -38,16 +43,9 @@ import {
38
43
  getPendingGates,
39
44
  getTaskStats,
40
45
  } from '../../shared/state-reader.js';
41
- import { injectContext, pass } from '../../shared/hook-response.js';
46
+ import { contextResult, runStandalone } from '../../shared/hook-response.js';
42
47
  import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registry.js';
43
-
44
- function readStdin() {
45
- try {
46
- return JSON.parse(readFileSync(0, 'utf8') || '{}');
47
- } catch {
48
- return {};
49
- }
50
- }
48
+ import { rotateActivityLogIfLarge } from '../../shared/activity-logger.js';
51
49
 
52
50
  function projectDir() {
53
51
  return process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -209,40 +207,45 @@ function claimBlock(dir, feature, sessionId) {
209
207
  }
210
208
  }
211
209
 
212
- async function main() {
210
+ /**
211
+ * @param {object} payload - parsed SessionStart stdin payload
212
+ * @returns {Promise<object|null>} a hookSpecificOutput context (worktree
213
+ * identity or active-feature summary), or null (pass/silent).
214
+ */
215
+ export async function run(payload) {
213
216
  try {
214
217
  const dir = projectDir();
215
218
  // The SessionStart payload carries session_id — the identity the claim
216
219
  // registry uses to tell "me" from "another session".
217
- const payload = readStdin();
218
220
  const sessionId = payload?.session_id || `pid-${process.pid}`;
219
221
 
220
222
  if (!stateExists(dir)) {
221
- pass();
222
- return;
223
+ return null;
223
224
  }
224
225
 
226
+ // Rotate the activity log before anything else touches it this session.
227
+ // Best-effort, never fatal to session start — rotateActivityLogIfLarge()
228
+ // is itself fail-open (returns false on any error).
229
+ try { rotateActivityLogIfLarge(dir); } catch { /* non-blocking */ }
230
+
225
231
  if (isSecondaryWorktree(dir)) {
226
232
  const feature = featureFromBranch(dir) || featureFromSingleDir(dir);
227
233
  if (!feature) {
228
- pass();
229
- return;
234
+ return null;
230
235
  }
231
- injectContext(buildWorktreeContext(dir, feature) + claimBlock(dir, feature, sessionId), 'SessionStart');
232
- return;
236
+ return contextResult(buildWorktreeContext(dir, feature) + claimBlock(dir, feature, sessionId), 'SessionStart');
233
237
  }
234
238
 
235
239
  // Primary root — active-feature summary (or silent if nothing's in progress)
236
240
  const summary = buildActiveFeatureSummary(dir);
237
241
  if (!summary) {
238
- pass();
239
- return;
242
+ return null;
240
243
  }
241
244
  const active = getActiveFeature(dir);
242
- injectContext(summary + (active?.name ? claimBlock(dir, active.name, sessionId) : ''), 'SessionStart');
245
+ return contextResult(summary + (active?.name ? claimBlock(dir, active.name, sessionId) : ''), 'SessionStart');
243
246
  } catch {
244
- pass();
247
+ return null;
245
248
  }
246
249
  }
247
250
 
248
- main().catch(() => pass());
251
+ await runStandalone(run);
@@ -443,15 +443,37 @@ def get_active_features(cwd):
443
443
 
444
444
 
445
445
  def get_last_hook(cwd):
446
- """Name of the most recent hook from .morph/logs/activity.json.
446
+ """Name of the most recent hook from .morph/logs/activity.jsonl.
447
+
448
+ activity-logger.js (R1.2) writes append-only JSONL now, one event per
449
+ line (`{"kind": "hook", ...}` or `{"kind": "event", ...}`), instead of a
450
+ single JSON doc rewritten whole on every call. The log can grow up to the
451
+ rotation threshold (2 MB, see ROTATE_THRESHOLD_BYTES in activity-logger.js)
452
+ between SessionStarts, and this function runs on every prompt redraw, so
453
+ only the trailing slice is read — never the whole file.
447
454
 
448
455
  The age is deliberately not shown: activity-logger.js only records
449
456
  HH:MM:SS, so any age computed from it is wrong across midnight.
450
457
  """
451
458
  try:
452
- raw = (Path(cwd) / '.morph' / 'logs' / 'activity.json').read_text(encoding='utf-8')
453
- hooks = json.loads(raw).get('hooks') or []
454
- return hooks[-1].get('name') or None if hooks else None
459
+ path = Path(cwd) / '.morph' / 'logs' / 'activity.jsonl'
460
+ size = path.stat().st_size
461
+ tail_bytes = 8192
462
+ with path.open('rb') as f:
463
+ if size > tail_bytes:
464
+ f.seek(-tail_bytes, 2)
465
+ tail = f.read().decode('utf-8', errors='ignore')
466
+ for line in reversed(tail.splitlines()):
467
+ line = line.strip()
468
+ if not line:
469
+ continue
470
+ try:
471
+ obj = json.loads(line)
472
+ except Exception:
473
+ continue
474
+ if obj.get('kind') == 'hook':
475
+ return obj.get('name') or None
476
+ return None
455
477
  except Exception:
456
478
  return None
457
479