doclogs-cli 0.1.0__py3-none-any.whl

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.
commands/__init__.py ADDED
File without changes
commands/capture.py ADDED
@@ -0,0 +1,51 @@
1
+ import typer
2
+ from typing import Optional
3
+
4
+ from helper.capture import build_capture_entry, merge_notes
5
+ from helper.capture_prompts import iter_interactive_tasks
6
+ from helper.entry import save_entry
7
+ from helper.git_collector import find_git_root
8
+
9
+
10
+ def _print_summary(entry, path) -> None:
11
+ typer.echo(f"\nšŸ“„ Captured to {path}")
12
+ typer.echo(f" branch: {entry.branch or 'n/a'}")
13
+ typer.echo(f" commits today: {len(entry.commits)}")
14
+
15
+
16
+ def register(app: typer.Typer):
17
+
18
+ @app.command("capture", help="Capture today's engineering work into local storage.")
19
+ def capture(
20
+ notes: Optional[str] = typer.Option(None, "-n", "--notes", help="Optional notes (skips interactive prompts)."),
21
+ no_interactive: bool = typer.Option(False, "--no-interactive", help="Skip questions; git-only capture."),
22
+ include_terminal: bool = typer.Option(False, help="Include optional terminal history evidence."),
23
+ include_tickets: bool = typer.Option(False, help="Include optional ticket IDs or issue references."),
24
+ ) -> None:
25
+ if find_git_root() is None:
26
+ typer.echo("āš ļø Not inside a git repo — git evidence will be empty.")
27
+
28
+ # Interactive: multiple tasks in one session
29
+ if not no_interactive and notes is None:
30
+ path = None
31
+ entry = None
32
+ saved_any = False
33
+
34
+ for batch, replace in iter_interactive_tasks():
35
+ entry = build_capture_entry(notes=batch, replace_notes=replace)
36
+ path = save_entry(entry)
37
+ saved_any = True
38
+ typer.echo(" āœ“ Task saved" if not replace else " āœ“ Task updated")
39
+
40
+ if not saved_any:
41
+ entry = build_capture_entry(notes=None)
42
+ path = save_entry(entry)
43
+
44
+ _print_summary(entry, path)
45
+ return
46
+
47
+ # Non-interactive: git only or single --notes
48
+ final_notes = merge_notes(notes)
49
+ entry = build_capture_entry(notes=final_notes)
50
+ path = save_entry(entry)
51
+ _print_summary(entry, path)
commands/config.py ADDED
@@ -0,0 +1,28 @@
1
+ import typer
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ import yaml
6
+
7
+ from helper.paths import config_path, ensure_config
8
+
9
+
10
+ def load_config(path: Path | None = None) -> dict[str, object]:
11
+ config_file = path or ensure_config()
12
+ if not config_file.exists():
13
+ raise typer.Exit(code=1, message=f"Config file not found: {config_file}")
14
+ with config_file.open("r", encoding="utf-8") as stream:
15
+ return yaml.safe_load(stream) or {}
16
+
17
+
18
+ def register(app: typer.Typer):
19
+
20
+ @app.command("config", help="Show the active DocLogs configuration.")
21
+ def config(
22
+ path: Optional[Path] = typer.Option(None, "-c", "--config", help="Path to the config file."),
23
+ ) -> None:
24
+ """Print the active DocLogs configuration."""
25
+ active = path or ensure_config()
26
+ settings = load_config(active)
27
+ typer.echo(f"Active DocLogs configuration ({active}):")
28
+ typer.echo(settings)
commands/generate.py ADDED
@@ -0,0 +1,49 @@
1
+ import re
2
+
3
+ import typer
4
+ from typing import Optional
5
+
6
+ from helper.generate import build_prompt
7
+ from helper.paths import posts_dir
8
+ from helper.story import find_story_text
9
+
10
+
11
+ def slugify(text: str) -> str:
12
+ text = text.strip().lower()
13
+ text = re.sub(r"[^\w\s-]", "", text)
14
+ return re.sub(r"[-\s]+", "-", text).strip("-")
15
+
16
+
17
+ def register(app: typer.Typer):
18
+
19
+ @app.command("generate", help="Generate a reusable artifact from a captured story.")
20
+ def generate(
21
+ artifact_type: str = typer.Argument(..., help="blog, linkedin, resume, interview, changelog"),
22
+ title: Optional[str] = typer.Option(None, "-t", "--title", help="Story title from doclog weekly."),
23
+ days: int = typer.Option(7, help="Days back to search for the story."),
24
+ ) -> None:
25
+ if not title:
26
+ typer.echo("Pass a story title: doclog generate blog -t \"Phase 0 completed\"")
27
+ raise typer.Exit(code=1)
28
+
29
+ story = find_story_text(title, days=days)
30
+ if not story:
31
+ typer.echo(f"Story not found: {title!r}")
32
+ typer.echo("Run doclog weekly to see available titles.")
33
+ raise typer.Exit(code=1)
34
+
35
+ prompt, findings, flags = build_prompt(artifact_type, story)
36
+
37
+ if flags:
38
+ typer.echo("āš ļø Sensitive patterns flagged — review before sending to an LLM:")
39
+ for flag in flags:
40
+ typer.echo(f" - [{flag.kind}] {flag.matched[:60]}")
41
+
42
+ output_dir = posts_dir()
43
+ output_dir.mkdir(parents=True, exist_ok=True)
44
+ out = output_dir / f"{slugify(title)}-{artifact_type}.md"
45
+ out.write_text(prompt, encoding="utf-8")
46
+
47
+ typer.echo(f"✨ Prompt saved to {out}")
48
+ if findings:
49
+ typer.echo(f" ({len(findings)} item(s) redacted in evidence)")
commands/sanitize.py ADDED
@@ -0,0 +1,33 @@
1
+ import typer
2
+ from typing import *
3
+
4
+ from helper.entry import entry_path_for, load_entry
5
+ from helper.sanitize import redact, sanitize_with_review, text_from_entry
6
+
7
+
8
+ def register(app: typer.Typer):
9
+
10
+ @app.command("sanitize", help="Sanitize content before sending to an LLM.")
11
+ def sanitize(
12
+ source: Optional[str] = typer.Argument(None, help="Text to sanitize. If omitted, uses today's capture."),
13
+ ) -> None:
14
+ if source:
15
+ text = source
16
+ else:
17
+ entry = load_entry(entry_path_for())
18
+ if not entry:
19
+ typer.echo("No capture for today. Run: doclog capture")
20
+ raise typer.Exit(code=1)
21
+ text = text_from_entry(entry)
22
+ sanitized, findings, flags = sanitize_with_review(text)
23
+ typer.echo("šŸ”’ Sanitized output:\n")
24
+ typer.echo(sanitized)
25
+ if findings:
26
+ typer.echo(f"\n({len(findings)} item(s) redacted)")
27
+ else:
28
+ typer.echo("\n(no sensitive patterns detected)")
29
+ if flags:
30
+ typer.echo("\nāš ļø Review before sending to LLM:")
31
+ for flag in flags:
32
+ snippet = flag.matched[:60] + ("..." if len(flag.matched) > 60 else "")
33
+ typer.echo(f" - [{flag.kind}] {snippet}")
commands/weekly.py ADDED
@@ -0,0 +1,27 @@
1
+ from ast import Return
2
+ import typer
3
+ from typing import *
4
+ from helper.weekly import build_story_candidates, load_entries_for_days
5
+
6
+
7
+ def register(app: typer.Typer):
8
+
9
+ @app.command("weekly", help="run workflows")
10
+ def weekly(
11
+ limit: int = typer.Option(5, help="Maximum number of story candidates to display."),
12
+ days: int = typer.Option(7, help="How many days back to review.")
13
+ ) -> None:
14
+ """Summarize the week and surface candidate stories for expansion."""
15
+ entries = load_entries_for_days(days)
16
+ if not entries:
17
+ typer.echo(f"šŸ“… No captures found in the last {days} days.")
18
+ typer.echo('Run: doclog capture --notes "what you worked on today"')
19
+ return
20
+ typer.echo(f"šŸ“… Weekly summary ({len(entries)} day(s) captured)")
21
+ candidates = build_story_candidates(entries, limit=limit)
22
+
23
+ if not candidates:
24
+ typer.echo("No story candidates yet.")
25
+ return
26
+ for i, story in enumerate(candidates, start=1):
27
+ typer.echo(f"{i}. [{story.source}] {story.title} ({story.date})")
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: doclogs-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for capturing engineering work and turning it into career artifacts.
5
+ Author: Mridul Tiwari
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/MridulTi/DocLogs
8
+ Project-URL: Repository, https://github.com/MridulTi/DocLogs
9
+ Project-URL: Issues, https://github.com/MridulTi/DocLogs/issues
10
+ Keywords: cli,engineering,career,devlog,documentation,resume
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Documentation
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: typer>=0.10.0
24
+ Requires-Dist: PyYAML>=6.0
25
+ Dynamic: license-file
26
+
27
+ # DocLogs
28
+
29
+ DocLogs is an engineer career operating system for capturing and reusing technical work.
30
+
31
+ ## What it solves
32
+
33
+ Engineers do valuable work every day: incident response, CI/CD and platform debugging, automation, infrastructure changes, PR reviews, and migrations. Most of that work is forgotten when it matters most: performance reviews, promotion packets, interviews, and professional storytelling.
34
+
35
+ DocLogs helps you turn day-to-day engineering activity into durable artifacts by:
36
+
37
+ - capturing evidence automatically from git, branches, PRs, and optional histories
38
+ - summarizing weekly progress and surfacing strong stories
39
+ - generating markdown artifacts for blog posts, LinkedIn, resumes, and interview prep
40
+ - keeping model usage provider-agnostic and safe with sanitization
41
+
42
+ ## Core commands
43
+
44
+ - `doclog capture`
45
+ - collect commits, repository activity, PR titles, tickets, and optional notes
46
+ - store structured daily entries in local storage
47
+ - `doclog weekly`
48
+ - review weekly work
49
+ - surface candidate stories worth expanding
50
+ - `doclog generate <type>`
51
+ - create reusable artifacts such as `blog`, `linkedin`, `resume`, `interview`, or `changelog`
52
+ - `doclog sanitize`
53
+ - sanitize captured content before any LLM request
54
+ - redact internal URLs, tokens, IP addresses, and other sensitive details
55
+
56
+ ## Recommended repository structure
57
+
58
+ ```
59
+ ./
60
+ ā”œā”€ā”€ README.md
61
+ ā”œā”€ā”€ config.yaml
62
+ ā”œā”€ā”€ commands/
63
+ │ ā”œā”€ā”€ capture.py
64
+ │ └── config.py
65
+ ā”œā”€ā”€ docs/
66
+ │ ā”œā”€ā”€ vision.md
67
+ │ └── architecture.md
68
+ ā”œā”€ā”€ doclog/
69
+ │ ā”œā”€ā”€ entries/
70
+ │ ā”œā”€ā”€ posts/
71
+ │ └── cache/
72
+ ā”œā”€ā”€ prompts/
73
+ │ ā”œā”€ā”€ blog.md
74
+ │ ā”œā”€ā”€ linkedin.md
75
+ │ └── resume.md
76
+ └── .copilot
77
+ ```
78
+
79
+ ## Architecture overview
80
+
81
+ DocLogs keeps the capture layer local and the generation layer model-agnostic. It is intentionally not a scheduler; use OS-level schedulers like `cron`, `systemd --user`, `launchd`, or Task Scheduler to invoke `doclog capture` at reminder times.
82
+
83
+ For implementation, start by focusing on:
84
+
85
+ 1. evidence collection and local storage
86
+ 2. weekly summaries and story selection
87
+ 3. safe prompt generation
88
+ 4. provider adapters for OpenAI, Ollama, Anthropic, Gemini, or other APIs
89
+
90
+ ## Getting started
91
+
92
+ ### Install from PyPI
93
+
94
+ ```bash
95
+ pip install doclogs-cli
96
+ doclog --help
97
+ ```
98
+
99
+ ### Install from source (development)
100
+
101
+ ```bash
102
+ git clone https://github.com/MridulTi/DocLogs.git
103
+ cd DocLogs
104
+ python -m venv .venv
105
+ source .venv/bin/activate
106
+ pip install -e .
107
+ doclog --help
108
+ ```
109
+
110
+ ### First run
111
+
112
+ Data is stored under `~/.doclog/` (entries, posts, config). On first use, a default `config.yaml` is created there.
113
+
114
+ To keep using a project-local folder instead:
115
+
116
+ ```bash
117
+ export DOCLOG_HOME="$PWD/doclog"
118
+ doclog capture
119
+ ```
120
+
121
+ ### Daily use
122
+
123
+ 1. Configure `~/.doclog/config.yaml` with your preferred LLM provider (created automatically on first run).
124
+ 2. Add a scheduler entry outside the CLI to invoke `doclog capture` at your preferred check-in time.
125
+ 3. Capture daily progress and generate reusable career artifacts from the same captured story.
126
+
127
+ ## Publish to PyPI (GitHub Actions)
128
+
129
+ Publishing runs via GitHub Actions — no local `twine upload` needed.
130
+
131
+ 1. Configure [trusted publishing](docs/publishing.md) on PyPI (leave Environment blank)
132
+ 2. Push a version tag:
133
+
134
+ ```bash
135
+ git tag v0.1.0
136
+ git push origin v0.1.0
137
+ ```
138
+
139
+ The workflow uploads to PyPI automatically.
140
+
141
+ See [docs/publishing.md](docs/publishing.md) for full setup.
@@ -0,0 +1,31 @@
1
+ main.py,sha256=jTskRCW2BEl8GBNzN7PM8QgoQmTsMsiwYrOnHidOdpE,643
2
+ commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ commands/capture.py,sha256=5pXv5wUwv_yvAa6W6HVOeqEOKXbkiedshKMGWNBuWeI,2067
4
+ commands/config.py,sha256=KEwcFrcW_SpXvoI3B4Hq6ejIoGRoZcoHqYbxX2Lobug,928
5
+ commands/generate.py,sha256=Af4YQC0mNwqgRukE6k4QunE-Jx6wM9LL_lRCSf75xNU,1796
6
+ commands/sanitize.py,sha256=2P9NwDPxcfkw_uHZRE7DtFgW2t_wU9_99YhdezSD1_k,1275
7
+ commands/weekly.py,sha256=iorqg59_KHbLQOtZ7BtleOxYJPMi93pNNP4nxIw6zwo,1112
8
+ doclogs_cli-0.1.0.dist-info/licenses/LICENSE,sha256=Qt8MKT0gY0r-pDAO1zEWfB7YHPycn9Bsy7YUDwF8daQ,1070
9
+ helper/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ helper/capture.py,sha256=pWYNYLFsLZ2I5QvujB-spRVSxAYMnDOKr5_x-5bDwjM,1101
11
+ helper/capture_prompts.py,sha256=OdGZXZKUufC5GuXYhpGBWbxN7VSJGzt5_0heQSyp8q0,3309
12
+ helper/entry.py,sha256=MpZJWtfIgE0cgbVXFoKfDu1vy2ZbQZwGfZ-DaT8KDcU,3055
13
+ helper/generate.py,sha256=1KN8NXEaYwjoiojyADTlU18G65GuNo29fMKFp1pPIPM,503
14
+ helper/git_collector.py,sha256=h494_kU8IrjuK-P-mnXt_5u363sOWe7CfBLjZtImqeI,2056
15
+ helper/paths.py,sha256=R2DtaomwTTwk72_5coPrsiezTwK4qYmy74SbTFPq_iM,1252
16
+ helper/prompts.py,sha256=wpDeHI3LFkPmtPkJ82BDUK5RRcp6ppnkFzIRmIoQIoc,581
17
+ helper/sanitize.py,sha256=GlBvw1XHJ5N7-LGrdUcuYpPmxtYaat4uPWeWewf_V2g,2576
18
+ helper/story.py,sha256=gBbKvRDRw4mWW4xYyh2uU53TLkTNvr9btW6vPPp-YOc,1039
19
+ helper/task_notes.py,sha256=LTPlgmcCzP3jGuIvIFEUB-_p-_Nc7L-j3b2VK4p2JDQ,2155
20
+ helper/weekly.py,sha256=W1dNO1nKzrmScf7xFQg1ZyDfYnqLH4W6rKthKMzulL0,1815
21
+ helper/templates/blog.md,sha256=W5sWQNpt11AqjS98KBKP89y5_pyJgWOKrtA3CgklWt4,304
22
+ helper/templates/changelog.md,sha256=L-xYIIljpT-05LOUxNvbZCKaVyZI6pdN5ftZm2isqiQ,245
23
+ helper/templates/config.yaml,sha256=zCCz1OHFyslfmEy-wJDx5ozDlTdINKTg-phoR4RNmws,299
24
+ helper/templates/interview.md,sha256=CyiCFBH0FRH7_5oUFIyEgMTVFIcrc1gjzgB8x6S9xVM,266
25
+ helper/templates/linkedin.md,sha256=IjQGUl6D_87DTGukattkEVSbTe5UV-Rqo0G1hXz9q7s,273
26
+ helper/templates/resume.md,sha256=oiodoV7cwGqutfUTaHH9S03ThhUrNPR9GEL_Rzwz_Vw,252
27
+ doclogs_cli-0.1.0.dist-info/METADATA,sha256=_QaR81UqPseuFAeXkYCMkjbzu4fEyesGaI-mgdzsJb0,4488
28
+ doclogs_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
29
+ doclogs_cli-0.1.0.dist-info/entry_points.txt,sha256=YqI5Rro1m2_IyiYM1Jass0Bi9JCjHP3efl6DodoYtYQ,36
30
+ doclogs_cli-0.1.0.dist-info/top_level.txt,sha256=FrdyAb6CbIi41Ma541tC-chBEt2lsWjGA__Tff-wWhc,21
31
+ doclogs_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ doclog = main:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mridul Tiwari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ commands
2
+ helper
3
+ main
helper/__init__.py ADDED
File without changes
helper/capture.py ADDED
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from helper.entry import (
4
+ entry_path_for,
5
+ load_entry,
6
+ new_empty_entry,
7
+ )
8
+ from helper.git_collector import enrich_entry_from_git
9
+
10
+
11
+ def merge_notes(*parts: str | None) -> str | None:
12
+ chunks = [p.strip() for p in parts if p and p.strip()]
13
+ if not chunks:
14
+ return None
15
+ return "\n\n".join(chunks)
16
+
17
+
18
+ def build_capture_entry(notes: str | None = None, *, replace_notes: bool = False):
19
+ path = entry_path_for()
20
+ existing = load_entry(path)
21
+
22
+ entry = new_empty_entry()
23
+ entry = enrich_entry_from_git(entry)
24
+
25
+ if existing:
26
+ entry.pr_titles = existing.pr_titles
27
+ entry.ticket_ids = existing.ticket_ids
28
+
29
+ if replace_notes:
30
+ entry.notes = notes
31
+ return entry
32
+
33
+ if notes:
34
+ if existing and existing.notes:
35
+ if notes not in existing.notes:
36
+ entry.notes = f"{existing.notes}\n\n{notes}"
37
+ else:
38
+ entry.notes = existing.notes
39
+ else:
40
+ entry.notes = notes
41
+ elif existing:
42
+ entry.notes = existing.notes
43
+
44
+ return entry
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ import re
5
+
6
+ from helper.entry import entry_path_for, load_entry
7
+ from helper.task_notes import complete_task, list_incomplete_tasks
8
+
9
+ def maybe_complete_stub() -> str | None:
10
+ entry = load_entry(entry_path_for())
11
+ if not entry or not entry.notes:
12
+ return None
13
+ pending = list_incomplete_tasks(entry.notes)
14
+ if not pending:
15
+ return None
16
+ typer.echo("\nšŸ“ Incomplete tasks from today:\n")
17
+ for num, summary in pending:
18
+ typer.echo(f" {num}. {summary[:70]}{'...' if len(summary) > 70 else ''}")
19
+ if not typer.confirm("\nAdd details to one of these?", default=True):
20
+ return None
21
+ choice = typer.prompt("Task number", default=str(pending[0][0])).strip()
22
+ task_num = int(choice)
23
+ typer.echo("\n--- Completing task ---\n")
24
+ followup = collect_followup_notes()
25
+ if not followup:
26
+ return None
27
+ return complete_task(entry.notes, task_num, followup)
28
+
29
+
30
+
31
+ # (stored label, prompt shown to user, required)
32
+ CAPTURE_QUESTIONS: list[tuple[str, str, bool]] = [
33
+ ("worked_on", "What did you work on today?", True),
34
+ ("impact", "What was the impact or outcome?", False),
35
+ ("blockers", "Any blockers, incidents, or debugging?", False),
36
+ ("remember", "Anything worth remembering for later?", False),
37
+ ]
38
+
39
+ FOLLOWUP_QUESTIONS: list[tuple[str, str]] = [
40
+ ("impact", "What was the impact or outcome?"),
41
+ ("blockers", "Any blockers, incidents, or debugging?"),
42
+ ("remember", "Anything worth remembering for later?"),
43
+ ]
44
+
45
+ def collect_followup_notes() -> str | None:
46
+ lines: list[str] = []
47
+ for key, prompt in FOLLOWUP_QUESTIONS:
48
+ answer = typer.prompt(prompt, default="").strip()
49
+ if answer:
50
+ lines.append(f"{key}: {answer}")
51
+ lines.append("status: complete")
52
+ return "\n".join(lines) if lines else None
53
+
54
+ def _next_task_number() -> int:
55
+ entry = load_entry(entry_path_for())
56
+ if not entry or not entry.notes:
57
+ return 1
58
+ nums = [int(n) for n in re.findall(r"### Task (\d+)", entry.notes)]
59
+ return max(nums, default=0) + 1
60
+
61
+
62
+ def iter_interactive_tasks():
63
+ updated = maybe_complete_stub()
64
+ if updated:
65
+ yield updated, True # ← replace entire notes
66
+ task_num = _next_task_number()
67
+ while True:
68
+ typer.echo(f"\n--- Task {task_num} ---\n")
69
+ batch = collect_interactive_notes()
70
+ if batch:
71
+ yield f"### Task {task_num}\n{batch}", False # ← append new task
72
+ if not typer.confirm("Add another task?", default=False):
73
+ break
74
+ task_num += 1
75
+
76
+ def collect_interactive_notes() -> str | None:
77
+ typer.echo("\nšŸ“‹ Daily capture — answer briefly (Enter to skip optional questions)\n")
78
+
79
+ worked_on = typer.prompt("What did you work on today?", default="").strip()
80
+ if not worked_on:
81
+ return None
82
+
83
+ lines = [f"worked_on: {worked_on}"]
84
+
85
+ if typer.confirm("Add more details later?", default=False):
86
+ lines.append("status: details_later")
87
+ return "\n".join(lines)
88
+
89
+ for key, prompt, required in CAPTURE_QUESTIONS[1:]: # skip worked_on, already asked
90
+ answer = typer.prompt(prompt, default="").strip()
91
+ if answer:
92
+ lines.append(f"{key}: {answer}")
93
+
94
+ lines.append("status: complete")
95
+ return "\n".join(lines)
helper/entry.py ADDED
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field, asdict
3
+ from datetime import date, timezone, datetime, timedelta
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ from helper.paths import entries_dir
10
+
11
+ SCHEMA_VERSION = 1
12
+
13
+
14
+ @dataclass
15
+ class GitCommit:
16
+ hash: str
17
+ subject: str
18
+ author: str
19
+ commited_at: str
20
+
21
+ @dataclass
22
+ class DailyEntry:
23
+ date: str # YYYY-MM-DD
24
+ captured_at: str # ISO 8601
25
+ schema_version: int = SCHEMA_VERSION
26
+ repository_path: str | None = None
27
+ branch: str | None = None
28
+ commits: list[GitCommit] = field(default_factory=list)
29
+ notes: str | None = None
30
+ pr_titles: list[str] = field(default_factory=list)
31
+ ticket_ids: list[str] = field(default_factory=list)
32
+
33
+
34
+ def entry_path_for(day:date | None = None) -> Path:
35
+ day = day or date.today()
36
+ return entries_dir() / f"{day.isoformat()}.yaml"
37
+
38
+ def new_empty_entry(notes: str | None = None) -> DailyEntry:
39
+ IST = timezone(timedelta(hours=5, minutes=30))
40
+ now = datetime.now(IST).replace(microsecond=0)
41
+ return DailyEntry(
42
+ date = now.date().isoformat(),
43
+ captured_at = now.isoformat(),
44
+ notes = notes,
45
+ )
46
+
47
+ def entry_to_dict(entry: DailyEntry) -> dict[str, Any]:
48
+ data = asdict(entry)
49
+ return {
50
+ "schema_version": data["schema_version"],
51
+ "date": data["date"],
52
+ "captured_at": data["captured_at"],
53
+ "repository": {
54
+ "path": data["repository_path"],
55
+ "branch": data["branch"],
56
+ },
57
+ "git": {
58
+ "commits": data["commits"],
59
+ },
60
+ "manual": {
61
+ "notes": data["notes"],
62
+ },
63
+ "evidence": {
64
+ "pr_titles": data["pr_titles"],
65
+ "ticket_ids": data["ticket_ids"],
66
+ },
67
+ }
68
+
69
+ def save_entry(entry: DailyEntry, path: Path | None = None) -> Path:
70
+ target = path or entry_path_for()
71
+ target.parent.mkdir(parents=True, exist_ok=True)
72
+ with target.open("w", encoding="utf-8") as stream:
73
+ yaml.safe_dump(
74
+ entry_to_dict(entry),
75
+ stream,
76
+ sort_keys=False,
77
+ allow_unicode=True,
78
+ )
79
+ return target
80
+
81
+ def load_entry(path: Path | None=None)-> DailyEntry | None:
82
+ target = path or entry_path_for()
83
+ if not target.exists():
84
+ return None
85
+
86
+ with target.open('r',encoding='utf-8') as stream:
87
+ data = yaml.safe_load(stream)
88
+ if not data:
89
+ return None
90
+
91
+ commits =[
92
+ GitCommit(**commit)
93
+ for commit in data.get(f"git",{}).get("commits",[])
94
+ ]
95
+
96
+ return DailyEntry(
97
+ date=data["date"],
98
+ captured_at=data["captured_at"],
99
+ schema_version=data.get("schema_version", SCHEMA_VERSION),
100
+ repository_path=data.get("repository", {}).get("path"),
101
+ branch=data.get("repository", {}).get("branch"),
102
+ commits=commits,
103
+ notes=data.get("manual", {}).get("notes"),
104
+ pr_titles=data.get("evidence", {}).get("pr_titles", []),
105
+ ticket_ids=data.get("evidence", {}).get("ticket_ids", []),
106
+ )
107
+
helper/generate.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from helper.prompts import load_prompt
4
+ from helper.sanitize import sanitize_with_review
5
+
6
+
7
+ def build_prompt(artifact_type: str, story_text: str) -> tuple[str, list, list]:
8
+ template = load_prompt(artifact_type)
9
+ sanitized, findings, flags = sanitize_with_review(story_text)
10
+
11
+ final = (
12
+ f"{template.strip()}\n\n"
13
+ f"---\n\n"
14
+ f"## Captured evidence (sanitized)\n\n"
15
+ f"{sanitized.strip()}\n"
16
+ )
17
+ return final, findings, flags
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from datetime import date, timedelta
6
+ from helper.entry import DailyEntry, GitCommit
7
+
8
+ def _run_git(args: list[str],cwd: Path) -> str:
9
+ result = subprocess.run(
10
+ ["git", *args],
11
+ cwd=cwd,
12
+ capture_output=True,
13
+ text=True,
14
+ check=False,
15
+ )
16
+ if result.returncode != 0:
17
+ return ""
18
+ return result.stdout.strip()
19
+
20
+ def find_git_root(start: Path | None = None) -> Path | None:
21
+ start = start or Path.cwd()
22
+ output = _run_git(["rev-parse","--show-toplevel"],start)
23
+ return Path(output) if output else None
24
+
25
+ def get_current_branch(repo_root: Path) -> str | None:
26
+ branch = _run_git(["rev-parse","--abbrev-ref","HEAD"],repo_root)
27
+ return branch or None
28
+
29
+
30
+ def collect_commits_for_day(repo_root: Path, day: date | None=None) -> list[GitCommit]:
31
+ day = day or date.today()
32
+ since = f"{day.isoformat()} 00:00:00"
33
+ until = f"{(day + timedelta(days=1)).isoformat()} 00:00:00"
34
+ fmt = "%H|%s|%an|%aI"
35
+
36
+ output = _run_git(
37
+ [
38
+ "log",
39
+ f"--since={since}",
40
+ f"--until={until}",
41
+ f"--pretty=format:{fmt}",
42
+ ],
43
+ repo_root,
44
+ )
45
+
46
+ commits: list[GitCommit] =[]
47
+ for line in output.splitlines():
48
+ if not line:
49
+ continue
50
+ hash_,subject,author,commited_at = line.split("|",3)
51
+ commits.append(
52
+ GitCommit(
53
+ hash=hash_,
54
+ subject=subject,
55
+ author=author,
56
+ commited_at=commited_at
57
+ )
58
+ )
59
+ return commits
60
+
61
+ def enrich_entry_from_git(entry: DailyEntry, repo_path: Path | None=None)-> DailyEntry:
62
+ start = repo_path or Path.cwd()
63
+ repo_root = find_git_root(start)
64
+ if repo_root is None:
65
+ return entry
66
+ entry.repository_path = str(repo_root)
67
+ entry.branch = get_current_branch(repo_root)
68
+ entry.commits = collect_commits_for_day(
69
+ repo_root,
70
+ date.fromisoformat(entry.date),
71
+ )
72
+ return entry
helper/paths.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ PACKAGE_DIR = Path(__file__).resolve().parent
8
+ TEMPLATES_DIR = PACKAGE_DIR / "templates"
9
+
10
+
11
+ def doclog_home() -> Path:
12
+ override = os.environ.get("DOCLOG_HOME")
13
+ home = Path(override).expanduser() if override else Path.home() / ".doclog"
14
+ home.mkdir(parents=True, exist_ok=True)
15
+ return home
16
+
17
+
18
+ def entries_dir() -> Path:
19
+ path = doclog_home() / "entries"
20
+ path.mkdir(parents=True, exist_ok=True)
21
+ return path
22
+
23
+
24
+ def posts_dir() -> Path:
25
+ path = doclog_home() / "posts"
26
+ path.mkdir(parents=True, exist_ok=True)
27
+ return path
28
+
29
+
30
+ def cache_dir() -> Path:
31
+ path = doclog_home() / "cache"
32
+ path.mkdir(parents=True, exist_ok=True)
33
+ return path
34
+
35
+
36
+ def prompts_dir() -> Path:
37
+ return TEMPLATES_DIR
38
+
39
+
40
+ def config_path() -> Path:
41
+ return doclog_home() / "config.yaml"
42
+
43
+
44
+ def default_config_path() -> Path:
45
+ return TEMPLATES_DIR / "config.yaml"
46
+
47
+
48
+ def ensure_config() -> Path:
49
+ path = config_path()
50
+ if path.exists():
51
+ return path
52
+ default = default_config_path()
53
+ if default.exists():
54
+ shutil.copy(default, path)
55
+ else:
56
+ path.write_text("llm:\n provider: openai\n", encoding="utf-8")
57
+ return path
helper/prompts.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from helper.paths import prompts_dir
4
+
5
+ VALID_TYPES = {"blog", "linkedin", "resume", "interview", "changelog"}
6
+
7
+
8
+ def load_prompt(artifact_type: str) -> str:
9
+ kind = artifact_type.strip().lower()
10
+ if kind not in VALID_TYPES:
11
+ raise ValueError(
12
+ f"Unknown artifact type: {artifact_type}. Choose from: {', '.join(sorted(VALID_TYPES))}"
13
+ )
14
+ path = prompts_dir() / f"{kind}.md"
15
+ if not path.exists():
16
+ raise FileNotFoundError(f"Prompt template not found: {path}")
17
+ return path.read_text(encoding="utf-8")
helper/sanitize.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ import re
4
+
5
+ from helper.entry import DailyEntry
6
+
7
+ @dataclass
8
+ class RedactionFinding:
9
+ kind: str
10
+ matched: str
11
+
12
+ @dataclass
13
+ class FlagFinding:
14
+ kind: str
15
+ matched: str
16
+
17
+
18
+
19
+ # (name, regex pattern)
20
+ PATTERNS: list[tuple[str, re.Pattern[str]]] = [
21
+ ("internal_url", re.compile(r"https?://[^\s]*\.(?:internal|corp|local)[^\s]*", re.I)),
22
+ ("private_ip", re.compile(r"\b(?:10|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b")),
23
+ ("api_key", re.compile(r"\b(?:sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16})\b")),
24
+ ("bearer_token", re.compile(r"\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b", re.I)),
25
+ ]
26
+ PLACEHOLDERS = {
27
+ "internal_url": "[REDACTED:internal_url]",
28
+ "private_ip": "[REDACTED:private_ip]",
29
+ "api_key": "[REDACTED:api_key]",
30
+ "bearer_token": "[REDACTED:token]",
31
+ }
32
+
33
+ FLAG_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
34
+ ("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
35
+ ("password_assignment", re.compile(r"(?i)(password|passwd|secret|token)\s*[:=]\s*\S+")),
36
+ ("aws_account", re.compile(r"\b\d{12}\b")), # 12-digit AWS account IDs
37
+ ]
38
+
39
+
40
+ def redact(text:str)-> tuple(str, list[RedactionFinding]):
41
+ finding: list[RedactionFinding] = []
42
+ result = text
43
+
44
+ for (kind,pattern) in PATTERNS:
45
+ for match in pattern.findall(result):
46
+ finding.append(RedactionFinding(kind=kind, matched=match))
47
+ placeholder = PLACEHOLDERS.get(kind, "[REDACTED]")
48
+ result = result.replace(match,placeholder)
49
+ return result, finding
50
+
51
+
52
+ def text_from_entry(entry: DailyEntry) -> str:
53
+ parts: list[str] = []
54
+ if entry.notes:
55
+ parts.append(entry.notes)
56
+ for commit in entry.commits:
57
+ parts.append(commit.subject)
58
+ return "\n".join(parts)
59
+
60
+
61
+ def sanitize_entry(entry: DailyEntry) -> tuple[str, list[RedactionFinding]]:
62
+ return redact(text_from_entry(entry))
63
+
64
+ def scan_flags(text:str) -> list[FlagFinding]:
65
+ flags:list[FlagFinding] = []
66
+ seen: set[str] = set()
67
+
68
+ for kind, pattern in FLAG_PATTERNS:
69
+ for match in pattern.findall(text):
70
+ matched = match if isinstance(match, str) else match[0]
71
+ key = matched.lower()
72
+ if key not in seen:
73
+ seen.add(key)
74
+ flags.append(FlagFinding(kind=kind, matched=matched))
75
+ return flags
76
+
77
+
78
+ def sanitize_with_review(text: str) -> tuple(str, list[RedactionFinding], list[FlagFinding]):
79
+ sanitized, findings = redact(text)
80
+ flags = scan_flags(text)
81
+ return sanitized, findings, flags
helper/story.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from helper.task_notes import extract_worked_on, parse_task_blocks
4
+ from helper.weekly import load_entries_for_days
5
+
6
+
7
+ def find_story_text(title: str, days: int = 7) -> str | None:
8
+ needle = title.strip().lower()
9
+ if not needle:
10
+ return None
11
+
12
+ # Allow matching without [incomplete] suffix from weekly output
13
+ needle = needle.removesuffix(" [incomplete]").strip()
14
+
15
+ entries = load_entries_for_days(days)
16
+ for entry in entries:
17
+ for commit in entry.commits:
18
+ if commit.subject.strip().lower() == needle:
19
+ return (
20
+ f"worked_on: {commit.subject}\n"
21
+ f"commit: {commit.hash}\n"
22
+ f"author: {commit.author}\n"
23
+ f"committed_at: {commit.commited_at}"
24
+ )
25
+
26
+ for block in parse_task_blocks(entry.notes):
27
+ task_title = extract_worked_on(block)
28
+ if task_title and task_title.lower() == needle:
29
+ return block
30
+
31
+ return None
helper/task_notes.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ DETAILS_LATER = "status: details_later"
6
+
7
+
8
+ def parse_task_blocks(notes: str | None) -> list[str]:
9
+ """Split manual notes into task blocks (legacy block + ### Task N sections)."""
10
+ if not notes or not notes.strip():
11
+ return []
12
+ blocks = re.split(r"(?=### Task \d+)", notes.strip())
13
+ return [block.strip() for block in blocks if block.strip()]
14
+
15
+
16
+ def extract_worked_on(block: str) -> str | None:
17
+ return _extract_worked_on(block)
18
+
19
+
20
+ def list_incomplete_tasks(notes: str | None) -> list[tuple[int, str]]:
21
+ if not notes:
22
+ return []
23
+
24
+ results: list[tuple[int, str]] = []
25
+
26
+ for block in parse_task_blocks(notes):
27
+ if DETAILS_LATER not in block:
28
+ continue
29
+ match = re.search(r"### Task (\d+)", block)
30
+ task_num = int(match.group(1)) if match else 0
31
+ summary = _extract_worked_on(block) or (f"Task {task_num}" if task_num else "Incomplete entry")
32
+ results.append((task_num, summary))
33
+
34
+ return results
35
+
36
+
37
+ def _extract_worked_on(block: str) -> str | None:
38
+ match = re.search(
39
+ r"worked_on:\s*(.+?)(?=\n(?:impact|blockers|remember|status:|### Task|\Z))",
40
+ block,
41
+ re.DOTALL,
42
+ )
43
+ if not match:
44
+ return None
45
+ return " ".join(match.group(1).split()) # flatten multiline YAML
46
+
47
+
48
+ def complete_task(notes: str, task_num: int, followup: str) -> str:
49
+ if task_num == 0:
50
+ pattern = r"^.*?(status: details_later.*?)(?=\n### Task \d+|\Z)"
51
+ worked_on = _extract_worked_on(notes) or ""
52
+ new_block = f"worked_on: {worked_on}\n{followup}"
53
+ else:
54
+ pattern = rf"### Task {task_num}\n.*?(?=(?:\n### Task \d+|\Z))"
55
+ match = re.search(pattern, notes, re.DOTALL)
56
+ if not match:
57
+ raise ValueError(f"Task {task_num} not found")
58
+ worked_on = _extract_worked_on(match.group(0)) or ""
59
+ new_block = f"### Task {task_num}\nworked_on: {worked_on}\n{followup}"
60
+
61
+ match = re.search(pattern, notes, re.DOTALL)
62
+ if not match:
63
+ raise ValueError(f"Task {task_num} not found")
64
+
65
+ return notes[: match.start()] + new_block + notes[match.end() :]
@@ -0,0 +1,9 @@
1
+ # DocLogs Blog Prompt Template
2
+
3
+ Use this template to turn a captured engineering story into a high-quality markdown blog post.
4
+
5
+ - start with the problem or incident
6
+ - explain how the issue was diagnosed
7
+ - describe the fix or automation
8
+ - share lessons learned
9
+ - keep the audience technical and practical
@@ -0,0 +1,7 @@
1
+ # DocLogs Changelog Prompt Template
2
+
3
+ Use this template to write a concise changelog entry from captured engineering work.
4
+
5
+ - summarize what changed for users or the team
6
+ - group related changes when helpful
7
+ - keep the tone factual and scannable
@@ -0,0 +1,20 @@
1
+ # DocLogs configuration
2
+
3
+ llm:
4
+ provider: openai
5
+
6
+ openai:
7
+ model: gpt-4o-mini
8
+ api_key: "${OPENAI_API_KEY}"
9
+
10
+ ollama:
11
+ endpoint: http://127.0.0.1:11434
12
+ model: "local-llm"
13
+
14
+ anthropic:
15
+ model: claude-3
16
+ api_key: "${ANTHROPIC_API_KEY}"
17
+
18
+ gemini:
19
+ model: gemini-pro
20
+ api_key: "${GEMINI_API_KEY}"
@@ -0,0 +1,8 @@
1
+ # DocLogs Interview Prompt Template
2
+
3
+ Use this template to prepare interview talking points from an engineering story.
4
+
5
+ - describe the situation and constraints
6
+ - explain your specific contribution
7
+ - highlight measurable outcomes
8
+ - note what you would do differently
@@ -0,0 +1,8 @@
1
+ # DocLogs LinkedIn Prompt Template
2
+
3
+ Use this template to create a concise, professional LinkedIn update from an engineering story.
4
+
5
+ - mention the impact or result
6
+ - highlight the challenge and solution
7
+ - keep it human and outcome-focused
8
+ - avoid sensitive internal details
@@ -0,0 +1,8 @@
1
+ # DocLogs Resume Prompt Template
2
+
3
+ Use this template to convert a story into a strong resume or promotion bullet.
4
+
5
+ - start with the action verb
6
+ - quantify impact when possible
7
+ - emphasize the technical achievement and result
8
+ - keep it concise and clear
helper/weekly.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import date, timedelta
5
+
6
+ from helper.entry import DailyEntry, load_entry
7
+ from helper.paths import entries_dir
8
+ from helper.task_notes import DETAILS_LATER, extract_worked_on, parse_task_blocks
9
+
10
+
11
+ @dataclass
12
+ class StoryCandidate:
13
+ title: str
14
+ source: str
15
+ date: str
16
+
17
+
18
+ def load_entries_for_days(days: int = 7) -> list[DailyEntry]:
19
+ entries: list[DailyEntry] = []
20
+ today = date.today()
21
+ for offset in range(days):
22
+ day = today - timedelta(days=offset)
23
+ path = entries_dir() / f"{day.isoformat()}.yaml"
24
+ entry = load_entry(path)
25
+ if entry:
26
+ entries.append(entry)
27
+ return entries
28
+
29
+
30
+ def build_story_candidates(
31
+ entries: list[DailyEntry] | None = None,
32
+ limit: int = 5,
33
+ ) -> list[StoryCandidate]:
34
+ candidates: list[StoryCandidate] = []
35
+ seen: set[str] = set()
36
+
37
+ for entry in entries or []:
38
+ for commit in entry.commits:
39
+ title = commit.subject.strip()
40
+ key = title.lower()
41
+ if title and key not in seen:
42
+ seen.add(key)
43
+ candidates.append(
44
+ StoryCandidate(title=title, source="commit", date=entry.date)
45
+ )
46
+
47
+ for block in parse_task_blocks(entry.notes):
48
+ title = extract_worked_on(block)
49
+ if not title:
50
+ continue
51
+ key = title.lower()
52
+ if key in seen:
53
+ continue
54
+ seen.add(key)
55
+ if DETAILS_LATER in block:
56
+ title = f"{title} [incomplete]"
57
+ candidates.append(
58
+ StoryCandidate(title=title, source="task", date=entry.date)
59
+ )
60
+
61
+ if len(candidates) >= limit:
62
+ break
63
+
64
+ return candidates[:limit]
main.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import importlib
5
+ import typer
6
+
7
+
8
+ app = typer.Typer(help="DocLogs CLI: capture engineering activity, review weekly progress, and generate reusable career artifacts.")
9
+
10
+ COMMANDS_DIR = Path(__file__).resolve().parent / "commands"
11
+
12
+ def discover_commands():
13
+ for path in COMMANDS_DIR.iterdir():
14
+ if path.is_file() and path.suffix == ".py" and path.name != "__init__.py":
15
+ module=importlib.import_module(f"commands.{path.stem}")
16
+ if hasattr(module,"register"):
17
+ module.register(app)
18
+
19
+ discover_commands()
20
+
21
+ if __name__ == "__main__":
22
+ app()