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 +0 -0
- commands/capture.py +51 -0
- commands/config.py +28 -0
- commands/generate.py +49 -0
- commands/sanitize.py +33 -0
- commands/weekly.py +27 -0
- doclogs_cli-0.1.0.dist-info/METADATA +141 -0
- doclogs_cli-0.1.0.dist-info/RECORD +31 -0
- doclogs_cli-0.1.0.dist-info/WHEEL +5 -0
- doclogs_cli-0.1.0.dist-info/entry_points.txt +2 -0
- doclogs_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- doclogs_cli-0.1.0.dist-info/top_level.txt +3 -0
- helper/__init__.py +0 -0
- helper/capture.py +44 -0
- helper/capture_prompts.py +95 -0
- helper/entry.py +107 -0
- helper/generate.py +17 -0
- helper/git_collector.py +72 -0
- helper/paths.py +57 -0
- helper/prompts.py +17 -0
- helper/sanitize.py +81 -0
- helper/story.py +31 -0
- helper/task_notes.py +65 -0
- helper/templates/blog.md +9 -0
- helper/templates/changelog.md +7 -0
- helper/templates/config.yaml +20 -0
- helper/templates/interview.md +8 -0
- helper/templates/linkedin.md +8 -0
- helper/templates/resume.md +8 -0
- helper/weekly.py +64 -0
- main.py +22 -0
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,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.
|
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
|
helper/git_collector.py
ADDED
|
@@ -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() :]
|
helper/templates/blog.md
ADDED
|
@@ -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,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
|
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()
|