memstack-skill-loader 4.16.0__tar.gz → 4.18.0__tar.gz

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 (73) hide show
  1. {memstack_skill_loader-4.16.0/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.18.0}/PKG-INFO +1 -1
  2. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/pyproject.toml +1 -1
  3. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/__init__.py +1 -1
  4. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/__main__.py +6 -0
  5. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/diary_ingest.py +193 -35
  6. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/memory_engine.py +266 -35
  7. memstack_skill_loader-4.18.0/src/memstack_skill_loader/memory_migrations.py +774 -0
  8. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/server.py +50 -7
  9. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
  10. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +2 -0
  11. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_diary_ingest.py +249 -2
  12. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_memory_engine.py +259 -1
  13. memstack_skill_loader-4.18.0/tests/test_memory_migrations.py +721 -0
  14. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/MANIFEST.in +0 -0
  15. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/README.md +0 -0
  16. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/setup.cfg +0 -0
  17. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/agent_runner.py +0 -0
  18. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/categories.py +0 -0
  19. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/compression.py +0 -0
  20. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/config.py +0 -0
  21. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/dashboard.html +0 -0
  22. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/dashboard.py +0 -0
  23. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/goal.py +0 -0
  24. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/goal_engine.py +0 -0
  25. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/hooks/__init__.py +0 -0
  26. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/hooks/deny_git.py +0 -0
  27. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/hooks/deny_manager_write.py +0 -0
  28. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/hooks/deny_reviewer_git.py +0 -0
  29. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/hooks/git_policy.py +0 -0
  30. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/index_meta.py +0 -0
  31. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/indexer.py +0 -0
  32. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/license.py +0 -0
  33. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/memory_db.py +0 -0
  34. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/proxy/__init__.py +0 -0
  35. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/proxy/_diag.py +0 -0
  36. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/proxy/body_parser.py +0 -0
  37. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/proxy/compressor.py +0 -0
  38. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/proxy/forwarder.py +0 -0
  39. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/proxy/pro_compressor.py +0 -0
  40. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/proxy/server.py +0 -0
  41. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/proxy/stats_tracker.py +0 -0
  42. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/search.py +0 -0
  43. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/session_context.py +0 -0
  44. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/skill_config.py +0 -0
  45. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/skill_descriptions.json +0 -0
  46. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/stats.py +0 -0
  47. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
  48. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader/version_check.py +0 -0
  49. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  50. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  51. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
  52. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
  53. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_advisor_strategy.py +0 -0
  54. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_body_parser_metric.py +0 -0
  55. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_builder_scope.py +0 -0
  56. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_code_read_savings.py +0 -0
  57. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_dashscan.py +0 -0
  58. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_failed_approaches.py +0 -0
  59. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_git_policy.py +0 -0
  60. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_index_meta_guard.py +0 -0
  61. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_license_environment.py +0 -0
  62. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_license_grace.py +0 -0
  63. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_memory_inspector.py +0 -0
  64. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_memory_scoring.py +0 -0
  65. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_memory_tools.py +0 -0
  66. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_pro_compressor.py +0 -0
  67. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_pro_skills_update.py +0 -0
  68. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_pro_source_resolution.py +0 -0
  69. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_project_dir_canonical.py +0 -0
  70. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_search_memory.py +0 -0
  71. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_session_context.py +0 -0
  72. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_skill_drift.py +0 -0
  73. {memstack_skill_loader-4.16.0 → memstack_skill_loader-4.18.0}/tests/test_wheel_contents.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.16.0
3
+ Version: 4.18.0
4
4
  Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
5
5
  Project-URL: Homepage, https://memstack.pro
6
6
  Project-URL: Documentation, https://memstack.pro/docs
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memstack-skill-loader"
7
- version = "4.16.0"
7
+ version = "4.18.0"
8
8
  description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """MemStack Skill Loader — MCP server for semantic skill search."""
2
2
 
3
- __version__ = "4.16.0"
3
+ __version__ = "4.18.0"
@@ -161,6 +161,12 @@ def main():
161
161
  elif len(sys.argv) > 2 and sys.argv[1] == "run":
162
162
  task = " ".join(sys.argv[2:])
163
163
  _run_agents(task)
164
+ elif len(sys.argv) > 1 and sys.argv[1] == "memory-migrate":
165
+ # Data migrations over the Memory Engine fact store. Dispatched here and
166
+ # nowhere else: no import-time hook, no call from MemoryStore, so a
167
+ # migration can only run because someone typed it.
168
+ from .memory_migrations import main as migrate_main
169
+ sys.exit(migrate_main(sys.argv[2:]))
164
170
  elif len(sys.argv) > 1 and sys.argv[1] == "proxy":
165
171
  import argparse
166
172
  parser = argparse.ArgumentParser(prog="memstack proxy")
@@ -6,6 +6,10 @@ module by path, exactly like the devlog-webhook:
6
6
 
7
7
  python -m memstack_skill_loader.diary_ingest "<markdown-backup-path>"
8
8
 
9
+ Pass ``--project NAME`` to override the derived project entirely. That is the
10
+ escape hatch for a project whose real name looks like another project's name
11
+ plus a suffix, which the derivation's third rung would otherwise shorten.
12
+
9
13
  Each fact line is stored through :class:`MemoryStore` with ``source_type='diary'``
10
14
  and ``source_ref`` set to the diary file path, canonicalized to an absolute
11
15
  forward-slash path so the same file re-ingested from any cwd/arg form dedupes.
@@ -18,10 +22,16 @@ already safe on disk before this starts.
18
22
 
19
23
  What changed from "always exits 0": the exit code now classifies the outcome,
20
24
  and every run over a diary that HAS a FACTS block prints one stdout summary
21
- (N ingested, M duplicate, K skipped) plus one indented reason per skipped line.
22
- Silence used to be the only output on success, which made a clean ingest
23
- indistinguishable from a hook that never ran, a wrong path, or a block that
24
- parsed to nothing.
25
+ (N ingested, M duplicate, K skipped into project 'X') plus one indented reason
26
+ per skipped line. Silence used to be the only output on success, which made a
27
+ clean ingest indistinguishable from a hook that never ran, a wrong path, or a
28
+ block that parsed to nothing.
29
+
30
+ The summary NAMES THE PROJECT it wrote to, and marks a namespace nothing has
31
+ ever been stored under as NEW. Counting alone was not enough: a diary misfiled
32
+ under a phantom namespace still reported a perfectly clean "17 ingested" and
33
+ exited 0, so the number a reader would check was right while the facts were
34
+ unreachable from any recall for the real project.
25
35
 
26
36
  exit 0 nothing was lost: no FACTS block at all (silent, nothing on stdout),
27
37
  or at least one fact ingested, or an all-duplicates re-run of an
@@ -230,54 +240,122 @@ def parse_facts_block(text: str) -> FactsParse:
230
240
  return FactsParse(present=True, facts=facts, errors=errors, linenos=linenos)
231
241
 
232
242
 
233
- def _resolves(name: str) -> bool:
243
+ def _resolves(name: str, failures: list[str] | None = None) -> bool:
234
244
  """True if the store already knows a project directory with this basename.
235
245
 
236
246
  Any non-empty result counts. Two hits mean an ambiguous basename collision,
237
247
  not "unknown" -- we only need to know the name exists, never which directory
238
248
  it maps to. Fail-open: an unreachable store answers "unknown", which leaves
239
249
  the raw slug in place rather than breaking the diary save.
250
+
251
+ Failing open is still right, but it used to be SILENT, and a silent
252
+ "unknown" is indistinguishable from a real one: an unreachable store minted
253
+ a phantom namespace for every project on the machine and said nothing. So
254
+ the first failure of a derivation warns, naming only the exception CLASS.
255
+ The exception's message is never echoed -- it is attacker-influenced free
256
+ text in the same sense a claim is, and the secrets policy forbids printing
257
+ what has not cleared an allowlist.
258
+
259
+ ``failures`` collects the classes seen so one derivation warns once rather
260
+ than once per rung; passing None keeps the single-shot behavior for direct
261
+ callers.
240
262
  """
241
263
  try:
242
264
  return bool(find_project_dirs_by_name(name))
243
- except Exception: # noqa: BLE001 -- never break the diary-save path
265
+ except Exception as exc: # noqa: BLE001 -- never break the diary-save path
266
+ if not failures:
267
+ _warn(
268
+ f"project lookup failed ({type(exc).__name__}); cannot tell a "
269
+ f"known project from a new one, so the derived slug stands as-is"
270
+ )
271
+ if failures is not None:
272
+ failures.append(type(exc).__name__)
244
273
  return False
245
274
 
246
275
 
247
- def _project_from_path(path: str) -> str:
248
- """Derive a dotted-path project slug from a diary filename.
276
+ def _derive_project(path: str) -> tuple[str, bool]:
277
+ """Derive a project slug from a diary filename, and say whether it resolved.
278
+
279
+ Returns ``(slug, resolved)``. ``resolved`` is True when some rung matched a
280
+ project the store already knows, and False when every rung fell through and
281
+ the raw slug is standing on its own. The caller needs that second value to
282
+ tell "this diary belongs to a known project" from "this diary is about to
283
+ mint a namespace nobody has ever seen", which is the distinction the summary
284
+ line now reports.
249
285
 
250
286
  ``2026-07-20-memstack-skill-loader.md`` -> ``memstack-skill-loader``.
251
287
 
252
- The diary skill writes ``{date}-{project}-N.md`` when a project already has a
253
- diary for that date. Deriving the slug from the filename alone turned that
254
- trailing ``-3`` into a separate project, stranding every ingested fact under
255
- a phantom slug that no recall for the real project would ever reach.
256
-
257
- So the raw name is checked against the store first, and only if it is unknown
258
- do we try stripping a trailing ``-<digits>``. Checking raw first is what keeps
259
- a project *genuinely* named with a trailing number (``proj-2``) resolving to
260
- itself -- the suffix is only ever stripped when doing so turns an unknown name
261
- into a known one. If neither resolves, the raw slug stands and downstream
262
- auto-registration handles the genuinely-new project as before.
288
+ Three rungs, tried in order, first hit wins:
289
+
290
+ 1. The raw slug, exactly. Checking raw FIRST is what keeps a project
291
+ genuinely named with a trailing number (``proj-2``) or a name that merely
292
+ looks like a suffix resolving to itself: a suffix is only ever stripped
293
+ when doing so turns an unknown name into a known one.
294
+ 2. A trailing ``-<digits>`` stripped. The diary skill writes
295
+ ``{date}-{project}-N.md`` when a project already has a diary for that
296
+ date, and that ``-3`` used to become a separate project.
297
+ 3. Trailing ``-segments`` stripped progressively, longest first. A diary
298
+ named ``{date}-{project}-{topic}.md`` -- the documented format is
299
+ ``{date}-{project}.md``, but descriptive names are written constantly --
300
+ filed every fact under ``{project}-{topic}``, invisible to any recall for
301
+ ``{project}``. ``foreman-turn-record`` tries ``foreman-turn``, then
302
+ ``foreman``. 495 facts across 37 phantom namespaces accumulated this way
303
+ before rung 3 existed.
304
+
305
+ Rung 3 is bounded by the segment count and never strips to empty, so the
306
+ last candidate it can offer is the first segment alone.
307
+
308
+ If nothing resolves the raw slug stands, exactly as before, and downstream
309
+ auto-registration handles the genuinely-new project -- but the caller now
310
+ knows to say so out loud.
311
+
312
+ Rung 3 is deliberately greedy, and that trades one silent failure for a
313
+ loud, correctable one: a genuinely distinct project whose name begins with
314
+ a known project's name (``deedstack-mobile`` alongside ``deedstack``) will
315
+ resolve to the shorter one and SAY it did. The escape hatch is the
316
+ ``--project`` flag, which skips derivation entirely.
263
317
  """
264
318
  stem = Path(path).stem
265
319
  m = _DATE_PREFIX.match(stem)
266
320
  raw = m.group(1) if m else stem
267
321
  slug = re.sub(r"[^a-z0-9_-]+", "-", raw.lower()).strip("-_") or "default"
268
322
 
269
- if _resolves(slug):
270
- return slug
323
+ # One list per derivation, so an unreachable store warns once, not per rung.
324
+ failures: list[str] = []
325
+
326
+ if _resolves(slug, failures):
327
+ return slug, True
271
328
 
272
329
  stripped = _SUFFIX_N.sub("", slug)
273
- if stripped and stripped != slug and _resolves(stripped):
330
+ if stripped and stripped != slug and _resolves(stripped, failures):
274
331
  _warn(
275
332
  f"project {slug!r} is unknown but {stripped!r} is a known project -- "
276
333
  f"ingesting under {stripped!r} (stripped a trailing -N diary suffix)"
277
334
  )
278
- return stripped
335
+ return stripped, True
336
+
337
+ # Rung 3: drop trailing '-segments' one at a time, longest candidate first.
338
+ # Bounded by the segment count; the loop stops with one segment left, so a
339
+ # candidate is never empty.
340
+ segments = slug.split("-")
341
+ for cut in range(len(segments) - 1, 0, -1):
342
+ candidate = "-".join(segments[:cut])
343
+ if candidate == stripped and stripped != slug:
344
+ continue # rung 2 already asked about this exact name
345
+ if _resolves(candidate, failures):
346
+ _warn(
347
+ f"project {slug!r} is unknown but {candidate!r} is a known "
348
+ f"project -- ingesting under {candidate!r} (stripped a "
349
+ f"descriptive diary suffix)"
350
+ )
351
+ return candidate, True
352
+
353
+ return slug, False
354
+
279
355
 
280
- return slug
356
+ def _project_from_path(path: str) -> str:
357
+ """The derived slug alone, for callers that do not need the resolved flag."""
358
+ return _derive_project(path)[0]
281
359
 
282
360
 
283
361
  def _canonical_source_ref(path: str) -> str:
@@ -337,16 +415,27 @@ def _summary(
337
415
  }
338
416
 
339
417
 
340
- def _report(path: str, summary: dict) -> None:
418
+ def _report(path: str, summary: dict, project: str, is_new: bool) -> None:
341
419
  """Print the one stdout summary. Callers can read this; stderr they ignore.
342
420
 
343
421
  Only ever called for a diary that HAS a FACTS block, so a diary without one
344
422
  stays silent on stdout as well as stderr.
423
+
424
+ The summary NAMES THE NAMESPACE IT WROTE TO. It did not, and that omission
425
+ is what let a whole class of misfiling stay invisible: every phantom-project
426
+ ingest printed a clean "N ingested" and exited 0, so the one number a reader
427
+ would have checked was correct and the one fact they needed was absent.
428
+ ``NEW`` marks a namespace no fact has ever been stored under, which is the
429
+ single cheapest signal that a derivation went somewhere unintended.
430
+
431
+ Everything stays on ONE line on purpose. A clean run must not emit a second
432
+ line, and must not put the word 'skipped' anywhere but the summary.
345
433
  """
434
+ label = f"NEW project {project!r}" if is_new else f"project {project!r}"
346
435
  print(
347
436
  f"{_PREFIX} {summary['ingested']} ingested, "
348
- f"{summary['duplicates']} duplicate, {len(summary['skipped'])} skipped: "
349
- f"{path}"
437
+ f"{summary['duplicates']} duplicate, {len(summary['skipped'])} skipped "
438
+ f"into {label}: {path}"
350
439
  )
351
440
  for reason in summary["skipped"]:
352
441
  print(f" skipped {reason}")
@@ -359,13 +448,22 @@ def _report(path: str, summary: dict) -> None:
359
448
  )
360
449
 
361
450
 
362
- def ingest_diary_file(path: str, store: MemoryStore | None = None) -> dict:
451
+ def ingest_diary_file(
452
+ path: str,
453
+ store: MemoryStore | None = None,
454
+ project: str | None = None,
455
+ ) -> dict:
363
456
  """Parse and ingest the FACTS block from a saved diary markdown file.
364
457
 
365
458
  Fail-open: returns a summary dict and never raises. Each problem prints one
366
459
  stderr line, and a diary with a FACTS block also gets one stdout summary.
367
460
  Returns ``{present, ingested, duplicates, attempted, skipped, errors,
368
- store_failure, exit_code}``.
461
+ store_failure, exit_code, project}``.
462
+
463
+ ``project`` overrides filename derivation entirely and silently: an explicit
464
+ name is a decision already made, so there is nothing to warn about. It is
465
+ the escape hatch for a project whose real name looks like another project's
466
+ name plus a suffix, which rung 3 of the derivation would otherwise shorten.
369
467
  """
370
468
  try:
371
469
  text = Path(path).read_text(encoding="utf-8")
@@ -385,7 +483,29 @@ def ingest_diary_file(path: str, store: MemoryStore | None = None) -> dict:
385
483
 
386
484
  if store is None:
387
485
  store = MemoryStore()
388
- project = _project_from_path(path)
486
+
487
+ if project:
488
+ # An explicit name is a decision already made: no derivation, no warning.
489
+ resolved = True
490
+ else:
491
+ project, resolved = _derive_project(path)
492
+
493
+ # "NEW" means no fact has EVER been stored under this namespace, asked
494
+ # before this run writes any. Deliberately a different question from
495
+ # _resolves, which consults the five legacy project_dir tables: a namespace
496
+ # that only ever held diary facts is invisible there, so it could never
497
+ # stop being "unknown" no matter how much it accumulated. This check reads
498
+ # memory_facts itself, so a phantom is announced once and only once.
499
+ is_new = False
500
+ if not resolved:
501
+ try:
502
+ is_new = store.stats(project=project)["total"] == 0
503
+ except Exception as exc: # noqa: BLE001 -- never break the diary save
504
+ _warn(
505
+ f"could not check whether {project!r} is a new namespace "
506
+ f"({type(exc).__name__}); reporting it without the NEW marker"
507
+ )
508
+
389
509
  source_ref = _canonical_source_ref(path)
390
510
 
391
511
  skipped: list[str] = list(parsed.errors) # malformed lines, already warned
@@ -437,26 +557,64 @@ def ingest_diary_file(path: str, store: MemoryStore | None = None) -> dict:
437
557
  skipped=skipped,
438
558
  store_failure=store_failure,
439
559
  )
440
- _report(path, summary)
560
+ summary["project"] = project
561
+ _report(path, summary, project, is_new)
441
562
  return summary
442
563
 
443
564
 
565
+ def _parse_argv(args: list[str]) -> tuple[str | None, str | None]:
566
+ """Split the CLI tail into ``(path, project)``. Never raises.
567
+
568
+ Accepts ``--project NAME`` and ``--project=NAME``. Unparseable input loses
569
+ nothing, so it warns and falls back to derivation rather than failing.
570
+ """
571
+ path: str | None = None
572
+ project: str | None = None
573
+ i = 0
574
+ while i < len(args):
575
+ arg = args[i]
576
+ if arg == "--project":
577
+ if i + 1 < len(args) and args[i + 1] and not args[i + 1].startswith("--"):
578
+ project = args[i + 1]
579
+ i += 2
580
+ continue
581
+ _warn("--project needs a value; deriving the project from the filename")
582
+ i += 1
583
+ continue
584
+ if arg.startswith("--project="):
585
+ value = arg.split("=", 1)[1]
586
+ if value:
587
+ project = value
588
+ else:
589
+ _warn("--project needs a value; deriving the project from the filename")
590
+ i += 1
591
+ continue
592
+ if path is None:
593
+ path = arg
594
+ i += 1
595
+ return path, project
596
+
597
+
444
598
  def main(argv: list[str]) -> int:
445
599
  """Hook entry point. Never raises; the exit code classifies the outcome.
446
600
 
447
601
  0 = nothing lost, 1 = content total loss, 2 = store failure. See the
448
- FAIL-OPEN CONTRACT in the module docstring.
602
+ FAIL-OPEN CONTRACT in the module docstring. Nothing added here changes an
603
+ exit code: every new warning path is still 0.
604
+
605
+ Usage: ``diary_ingest <diary.md> [--project NAME]``
449
606
  """
450
- if len(argv) < 2 or not argv[1]:
607
+ path, project = _parse_argv(list(argv[1:]))
608
+ if not path:
451
609
  # A caller that passed no path lost nothing, because nothing was read.
452
610
  _warn("no diary path provided; nothing to ingest")
453
611
  return 0
454
612
  try:
455
- return ingest_diary_file(argv[1])["exit_code"]
613
+ return ingest_diary_file(path, project=project)["exit_code"]
456
614
  except Exception as exc:
457
615
  # Absolute backstop: still never raises, but this is the store or
458
616
  # environment class of failure, so it is loud rather than silent.
459
- _warn(f"unexpected error ingesting {argv[1]!r}: {exc}")
617
+ _warn(f"unexpected error ingesting {path!r}: {exc}")
460
618
  print(
461
619
  f"{_PREFIX} aborted, nothing ingested: {type(exc).__name__}: {exc}"
462
620
  )