theslopmachine 0.7.3 → 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL.md +1 -1
- package/README.md +11 -1
- package/RELEASE.md +16 -0
- package/assets/agents/developer.md +2 -1
- package/assets/agents/slopmachine-claude.md +28 -20
- package/assets/agents/slopmachine.md +22 -18
- package/assets/claude/agents/developer.md +2 -1
- package/assets/skills/beads-operations/SKILL.md +1 -1
- package/assets/skills/clarification-gate/SKILL.md +6 -4
- package/assets/skills/claude-worker-management/SKILL.md +5 -5
- package/assets/skills/developer-session-lifecycle/SKILL.md +11 -9
- package/assets/skills/evaluation-triage/SKILL.md +3 -2
- package/assets/skills/final-evaluation-orchestration/SKILL.md +12 -19
- package/assets/skills/submission-packaging/SKILL.md +14 -11
- package/assets/skills/verification-gates/SKILL.md +4 -4
- package/assets/slopmachine/templates/AGENTS.md +2 -1
- package/assets/slopmachine/templates/CLAUDE.md +2 -1
- package/assets/slopmachine/utils/__pycache__/normalize_claude_session.cpython-311.pyc +0 -0
- package/assets/slopmachine/utils/claude_live_launch.mjs +2 -2
- package/assets/slopmachine/utils/normalize_claude_session.py +162 -27
- package/assets/slopmachine/utils/package_claude_session.mjs +120 -23
- package/assets/slopmachine/utils/prepare_evaluation_prompt.mjs +41 -0
- package/assets/slopmachine/workflow-init.js +1 -1
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/constants.js +1 -0
- package/src/init.js +117 -28
- package/src/send-data.js +4 -4
|
@@ -17,7 +17,7 @@ Use this skill only during `P9 Submission Packaging`.
|
|
|
17
17
|
- packaging is incomplete until every required final artifact path has been verified to exist
|
|
18
18
|
- do not stop packaging for approval, status confirmation, or handoff once this phase has begun; continue until the package is complete
|
|
19
19
|
- when a task or platform question id exists such as `TASK-123`, use that exact id as the final deliverable/archive name without adding an extra `ID-` prefix
|
|
20
|
-
- normalize
|
|
20
|
+
- normalize `metadata.json.project_type` to the exact allowed values `fullstack`, `backend`, `android`, `ios`, `desktop`, or `web`, and keep any packaging labels aligned with that same delivered project reality
|
|
21
21
|
|
|
22
22
|
## Required final structure
|
|
23
23
|
|
|
@@ -32,16 +32,16 @@ The final delivery layout in the parent project root must be:
|
|
|
32
32
|
- `sessions/`
|
|
33
33
|
- `sessions/<label>.json` for every tracked developer session, including `develop-N.json` and `bugfix-N.json` when present
|
|
34
34
|
- for Claude-backed developer sessions:
|
|
35
|
-
- `claude-sessions.zip` in the parent root containing the
|
|
35
|
+
- `claude-sessions.zip` in the parent root containing only the tracked relevant Claude session artifacts once
|
|
36
36
|
- no `sessions/` directory is required when all tracked developer sessions are Claude-backed
|
|
37
37
|
- `metadata.json`
|
|
38
38
|
- `.tmp/`
|
|
39
39
|
- `audit_report-<N>.md` only for bugfix-triggering `partial pass` audits
|
|
40
|
-
- `audit_report-<N>-fix_check
|
|
40
|
+
- `audit_report-<N>-fix_check.md` when present
|
|
41
41
|
- `test_coverage_and_readme_audit_report.md`
|
|
42
42
|
- `repo/`
|
|
43
43
|
|
|
44
|
-
In the clean two-bugfix path, `.tmp/` should end with at least 5 required markdown reports once the final coverage/README audit is included: 2 kept partial-pass audit reports,
|
|
44
|
+
In the clean two-bugfix path, `.tmp/` should end with at least 5 required markdown reports once the final coverage/README audit is included: 2 kept partial-pass audit reports, 2 corresponding single fix-check reports, and the final coverage/README audit report.
|
|
45
45
|
|
|
46
46
|
Inside the delivered `repo/`, the repository must remain self-sufficient:
|
|
47
47
|
|
|
@@ -56,7 +56,7 @@ No screenshots are required as packaging artifacts.
|
|
|
56
56
|
## Required packaging actions
|
|
57
57
|
|
|
58
58
|
- verify the parent-root package structure matches the blueprint exactly
|
|
59
|
-
- make sure parent-root `../metadata.json` is complete
|
|
59
|
+
- make sure parent-root `../metadata.json` is complete, reflects the delivered project truthfully, and contains only these keys: `prompt`, `project_type`, `frontend_language`, `backend_language`, `database`, `frontend_framework`, `backend_framework`
|
|
60
60
|
- verify parent-root `../docs/design.md` exists and reflects the final delivered design when applicable
|
|
61
61
|
- verify parent-root `../docs/api-spec.md` exists and reflects the final delivered interfaces when applicable
|
|
62
62
|
- verify parent-root `../docs/test-coverage.md` exists and reflects the final delivered verification coverage
|
|
@@ -74,7 +74,7 @@ No screenshots are required as packaging artifacts.
|
|
|
74
74
|
- verify parent-root `../.tmp/` exists and contains the required audit and fix-check reports
|
|
75
75
|
- verify parent-root `../.tmp/test_coverage_and_readme_audit_report.md` exists from the final post-bugfix coverage/README audit
|
|
76
76
|
- export all tracked developer sessions before closing packaging
|
|
77
|
-
- when packaging succeeds, update workflow metadata to mark `packaging_completed` as true
|
|
77
|
+
- when packaging succeeds and any tracked live Claude tmux lanes have been stopped, update workflow metadata to mark `packaging_completed` as true
|
|
78
78
|
|
|
79
79
|
## Session export sequence
|
|
80
80
|
|
|
@@ -85,18 +85,20 @@ Export tracked developer sessions from metadata using the tracked lane labels, f
|
|
|
85
85
|
|
|
86
86
|
For session export:
|
|
87
87
|
|
|
88
|
-
1. if at least one tracked developer session backend is `claude` or `claude-live`, run `node ~/slopmachine/utils/package_claude_session.mjs --cwd "$PWD" --session-
|
|
88
|
+
1. if at least one tracked developer session backend is `claude` or `claude-live`, gather the tracked Claude `session_id` values from metadata and run `node ~/slopmachine/utils/package_claude_session.mjs --cwd "$PWD" --session-ids <tracked-claude-session-id-1,tracked-claude-session-id-2,...> --label claude-sessions --output ../claude-sessions.zip`
|
|
89
89
|
2. if `<backend>` is neither `claude` nor `claude-live`, run `opencode export <session-id> > ../session-export-<label>.raw`
|
|
90
90
|
3. if `<backend>` is neither `claude` nor `claude-live`, run `python3 ~/slopmachine/utils/strip_session_parent.py ../session-export-<label>.raw --output ../sessions/<label>.json`
|
|
91
91
|
|
|
92
92
|
Where `<backend>` comes from the tracked developer session record in metadata.
|
|
93
93
|
Use `opencode` when no explicit backend field exists or when the backend is not Claude-backed.
|
|
94
|
-
For Claude-backed sessions, the package helper resolves the Claude
|
|
94
|
+
For Claude-backed sessions, the package helper resolves the tracked Claude session artifacts under `~/.claude/projects/` from the tracked `session_id` values plus the current project `cwd`, copies only those tracked `session_id.jsonl` files and matching `session_id/` companion directories when present, normalizes the copied JSONL session files by flattening channel-originated user turns, and packages only that tracked set once. Do not sweep unrelated random Claude sessions into the archive just because they share the same Claude project directory.
|
|
95
95
|
|
|
96
96
|
After those steps:
|
|
97
97
|
|
|
98
98
|
- verify every non-Claude developer session has been exported into `../sessions/<label>.json`
|
|
99
|
-
- verify Claude-backed sessions have been packaged once into `../claude-sessions.zip`
|
|
99
|
+
- verify Claude-backed sessions have been packaged once into `../claude-sessions.zip` using the tracked relevant Claude session ids rather than the whole local Claude project directory
|
|
100
|
+
- after Claude-backed session packaging succeeds, stop each tracked live Claude runtime with `node ~/slopmachine/utils/claude_live_stop.mjs --runtime-dir <runtime_dir>` before marking packaging complete
|
|
101
|
+
- verify each stopped Claude runtime no longer has a live tmux session before closing packaging
|
|
100
102
|
- treat only the raw `../session-export-<label>.raw` files as temporary packaging intermediates
|
|
101
103
|
- remove the raw `../session-export-<label>.raw` files before closing packaging
|
|
102
104
|
- if the required utilities, metadata session ids, or output files are missing, packaging is not ready to continue
|
|
@@ -127,13 +129,14 @@ After those steps:
|
|
|
127
129
|
- confirm the cleanup helper has been run and that no known recursive cleanup targets remain in the delivered repo tree
|
|
128
130
|
- confirm no environment-dependent dependency directories, editor-state folders, runtime caches, or workflow utility scripts are packaged into the delivered product
|
|
129
131
|
- confirm parent-root `../.tmp/` exists and contains the required kept `audit_report-<N>.md` files for partial-pass audits only
|
|
130
|
-
- confirm every bugfix-triggering audit number has its matching `audit_report-<N>-fix_check
|
|
132
|
+
- confirm every bugfix-triggering audit number has its matching `audit_report-<N>-fix_check.md` file when fix checks were required
|
|
131
133
|
- confirm parent-root `../.tmp/test_coverage_and_readme_audit_report.md` exists and is the final replaced copy rather than a numbered variant
|
|
132
134
|
- confirm parent-root `../docs/test-coverage.md` explains the tested flows, mapped tests, and coverage boundaries
|
|
133
135
|
- confirm every non-Claude developer session exists under parent-root `../sessions/` using the tracked `<label>.json` names
|
|
134
136
|
- confirm Claude-backed developer sessions exist in the parent root as `claude-sessions.zip`
|
|
137
|
+
- confirm no tracked Claude live tmux session is still running after packaging finishes
|
|
135
138
|
- confirm parent-root `../docs/` remains consistent as an external reference set when workflow policy still requires it, but the delivered repo does not depend on it
|
|
136
|
-
- confirm parent-root metadata fields are populated correctly
|
|
139
|
+
- confirm parent-root metadata fields are populated correctly and no extra keys exist in `../metadata.json`
|
|
137
140
|
- confirm workflow metadata marks `packaging_completed` as true
|
|
138
141
|
- confirm no `submission/` directory or other obsolete packaging artifact structure remains
|
|
139
142
|
|
|
@@ -12,8 +12,8 @@ Use this skill after development begins whenever you are reviewing work, decidin
|
|
|
12
12
|
- load this skill before review, acceptance, rejection, runtime gate interpretation, hardening readiness decisions, or broad-gate decisions
|
|
13
13
|
- treat it as owner-side review and gate guidance, not developer-visible text
|
|
14
14
|
- use this skill as the source of truth for owner-side verification, review pressure, and gate interpretation
|
|
15
|
-
-
|
|
16
|
-
-
|
|
15
|
+
- do not pause execution for human approval while using this skill; continue reviewing, rejecting, fixing, and rerunning until the work qualifies
|
|
16
|
+
- clarification completion and `P8 Final Readiness Decision` are internal workflow transitions, not user-stop gates; do not pause execution just to summarize progress or ask the user whether to continue
|
|
17
17
|
|
|
18
18
|
## Documentation and repo hygiene
|
|
19
19
|
|
|
@@ -163,7 +163,7 @@ Any earlier extra Docker run needs a concrete blocker-based justification.
|
|
|
163
163
|
|
|
164
164
|
Use evidence such as internal metadata files, structured Beads comments, verification command results, and file/project-state checks.
|
|
165
165
|
|
|
166
|
-
- clarification requires the `clarification-gate` conditions plus
|
|
166
|
+
- clarification requires the `clarification-gate` conditions plus an internally accepted clarification record that is ready to roll directly into planning
|
|
167
167
|
- planning requires the `developer-session-lifecycle` and planning-gate conditions plus a fresh planning-oriented start and the required documentation and repo hygiene state when relevant
|
|
168
168
|
- planning exit also requires explicit owner review that the accepted planning artifacts cover the section-addressable contract deeply enough for later implementation: in-scope and out-of-scope, actors and success paths, modules, business rules, state machines, permissions, validation, verification strategy, checkpoints, and definition of done when applicable
|
|
169
169
|
- planning exit does not pass if those sections exist only nominally or remain too vague to drive implementation without broad reinvention
|
|
@@ -214,7 +214,7 @@ Use evidence such as internal metadata files, structured Beads comments, verific
|
|
|
214
214
|
- before `P7`, for `fullstack` and `web` projects, require an explicit frontend unit-test verdict backed by direct file-level evidence; if frontend unit tests are missing or insufficient, treat that as a critical gap
|
|
215
215
|
- before `P7`, require repo-local build/preview/config traceability plus disclosure in `README.md` of feature flags, debug/demo surfaces, and mock defaults when those surfaces exist
|
|
216
216
|
- before `P7`, require logging and validation contracts to be statically traceable enough that the owner can review them from the repo plus external references when needed
|
|
217
|
-
- final evaluation readiness requires the audit-numbered `P7` model under `../.tmp/`; only `partial pass` fresh evaluations leave persisted `audit_report-<N>.md` files, `fail` audits route back to the latest `develop-N` session and discard their working report after triage, `pass` audits discard their working report and rerun fresh evaluation, `partial pass` audits open scoped `bugfix-N` sessions whose fix checks are stored
|
|
217
|
+
- final evaluation readiness requires the audit-numbered `P7` model under `../.tmp/`; only `partial pass` fresh evaluations leave persisted `audit_report-<N>.md` files, `fail` audits route back to the latest `develop-N` session and discard their working report after triage, `pass` audits discard their working report and rerun fresh evaluation, `partial pass` audits open scoped `bugfix-N` sessions whose fix checks are stored in a single replace-in-place `audit_report-<N>-fix_check.md`, and the last subphase of `P7` runs `test_coverage_and_readme_audit_report.md` with up to 3 remediation attempts before carrying the latest report forward
|
|
218
218
|
- before leaving `P7`, if `README.md` documents `docker compose up --build` and/or `./run_tests.sh` as part of the delivered external contract, run those exact commands on the final state and require them to pass before moving to `P8`
|
|
219
219
|
- if the `P7` issue-fix loop materially reopens the integrated verification boundary, route it back through integrated verification before continuing with follow-up fix verification
|
|
220
220
|
- before leaving `P7`, require the parent-root `../.tmp/test_coverage_and_readme_audit_report.md` to exist from the last `P7` subphase; if it finds issues, route the fixes to the currently active recoverable developer session, replace the report, and rerun the audit, but stop after 3 remediation attempts and keep the latest report as the final carried-forward evidence
|
|
@@ -15,6 +15,7 @@ This file is the repo-local engineering rulebook for `slopmachine` projects.
|
|
|
15
15
|
- Read the code before making assumptions.
|
|
16
16
|
- Work in meaningful vertical slices.
|
|
17
17
|
- Do not call work complete while it is still shaky.
|
|
18
|
+
- Once given a bounded objective, keep going autonomously until it is complete or genuinely blocked; do not stop for reassurance or permission when a prompt-faithful default lets you proceed.
|
|
18
19
|
- Reuse and extend shared cross-cutting patterns instead of inventing incompatible local ones.
|
|
19
20
|
- Before coding, identify the actors or personas touched by the change and the concrete path to success for each one.
|
|
20
21
|
- Make important business rules explicit before coding: defaults, limits, allowed transitions, uniqueness, conflicts, reversals, retries, and ownership rules when they matter.
|
|
@@ -28,7 +29,7 @@ This file is the repo-local engineering rulebook for `slopmachine` projects.
|
|
|
28
29
|
|
|
29
30
|
- Preserve the full prompt intent, including implied business constraints.
|
|
30
31
|
- Do not weaken required actor models, operator flows, security controls, or lifecycle behavior for implementation convenience.
|
|
31
|
-
- If a requirement is ambiguous, choose the safest prompt-faithful behavior
|
|
32
|
+
- If a requirement is ambiguous, choose the safest prompt-faithful behavior and keep moving when a defensible default exists; surface the ambiguity only when it is genuinely blocking or materially changes the product contract.
|
|
32
33
|
- If the feature depends on business rules, make those rules traceable in code, tests, and `README.md` rather than leaving them implicit.
|
|
33
34
|
|
|
34
35
|
## Architecture Rules
|
|
@@ -15,6 +15,7 @@ This file is the repo-local engineering rulebook for `slopmachine-claude` projec
|
|
|
15
15
|
- Read the code before making assumptions.
|
|
16
16
|
- Work in meaningful vertical slices.
|
|
17
17
|
- Do not call work complete while it is still shaky.
|
|
18
|
+
- Once given a bounded objective, keep going autonomously until it is complete or genuinely blocked; do not stop for reassurance or permission when a prompt-faithful default lets you proceed.
|
|
18
19
|
- Reuse and extend shared cross-cutting patterns instead of inventing incompatible local ones.
|
|
19
20
|
- Before coding, identify the actors or personas touched by the change and the concrete path to success for each one.
|
|
20
21
|
- Make important business rules explicit before coding: defaults, limits, allowed transitions, uniqueness, conflicts, reversals, retries, and ownership rules when they matter.
|
|
@@ -28,7 +29,7 @@ This file is the repo-local engineering rulebook for `slopmachine-claude` projec
|
|
|
28
29
|
|
|
29
30
|
- Preserve the full prompt intent, including implied business constraints.
|
|
30
31
|
- Do not weaken required actor models, operator flows, security controls, or lifecycle behavior for implementation convenience.
|
|
31
|
-
- If a requirement is ambiguous, choose the safest prompt-faithful behavior
|
|
32
|
+
- If a requirement is ambiguous, choose the safest prompt-faithful behavior and keep moving when a defensible default exists; surface the ambiguity only when it is genuinely blocking or materially changes the product contract.
|
|
32
33
|
- If the feature depends on business rules, make those rules traceable in code, tests, and `README.md` rather than leaving them implicit.
|
|
33
34
|
|
|
34
35
|
## Architecture Rules
|
|
Binary file
|
|
@@ -36,8 +36,8 @@ const cwd = argv.cwd ? path.resolve(argv.cwd) : null
|
|
|
36
36
|
const lane = argv.lane
|
|
37
37
|
const agentName = argv.agent || 'developer'
|
|
38
38
|
const claudeCommand = argv['claude-command'] || 'claude'
|
|
39
|
-
const laneModel = argv.model || '
|
|
40
|
-
const laneEffort = argv.effort ||
|
|
39
|
+
const laneModel = argv.model || 'opus'
|
|
40
|
+
const laneEffort = argv.effort || 'medium'
|
|
41
41
|
const subagentModel = argv['subagent-model'] || 'sonnet'
|
|
42
42
|
const launchTimeoutMs = Number.parseInt(argv['timeout-ms'] || String(DEFAULT_LAUNCH_TIMEOUT_MS), 10)
|
|
43
43
|
const replace = argv.replace === '1'
|
|
@@ -7,7 +7,7 @@ import json
|
|
|
7
7
|
import re
|
|
8
8
|
import sys
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import Any
|
|
10
|
+
from typing import Any, Iterable
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
CHANNEL_MESSAGE_RE = re.compile(
|
|
@@ -15,6 +15,10 @@ CHANNEL_MESSAGE_RE = re.compile(
|
|
|
15
15
|
re.DOTALL,
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
+
CHANNEL_INSTRUCTION_RE = re.compile(r"slopmachine-[^\s\"]+", re.IGNORECASE)
|
|
19
|
+
CHANNEL_TAG_RE = re.compile(r"</?channel\b[^>]*>", re.IGNORECASE)
|
|
20
|
+
WEBHOOK_TERM_RE = re.compile(r"\b(?:webhook|UserPromptSubmit|StopFailure)\b", re.IGNORECASE)
|
|
21
|
+
|
|
18
22
|
|
|
19
23
|
def parse_args() -> argparse.Namespace:
|
|
20
24
|
parser = argparse.ArgumentParser(
|
|
@@ -37,6 +41,11 @@ def parse_args() -> argparse.Namespace:
|
|
|
37
41
|
action="store_true",
|
|
38
42
|
help="Keep channel origin metadata fields instead of stripping them",
|
|
39
43
|
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--recursive",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="If input is a directory, recursively normalize all .jsonl files into the output directory.",
|
|
48
|
+
)
|
|
40
49
|
return parser.parse_args()
|
|
41
50
|
|
|
42
51
|
|
|
@@ -49,55 +58,137 @@ def maybe_flatten_channel_content(content: str) -> tuple[str, bool]:
|
|
|
49
58
|
return body, True
|
|
50
59
|
|
|
51
60
|
|
|
61
|
+
def scrub_string(value: str) -> str:
|
|
62
|
+
flattened, _ = maybe_flatten_channel_content(value.strip())
|
|
63
|
+
normalized = flattened
|
|
64
|
+
normalized = CHANNEL_TAG_RE.sub("", normalized)
|
|
65
|
+
normalized = CHANNEL_INSTRUCTION_RE.sub("normalized-channel", normalized)
|
|
66
|
+
normalized = WEBHOOK_TERM_RE.sub("", normalized)
|
|
67
|
+
normalized = normalized.replace(
|
|
68
|
+
'Messages arrive as ordinary inbound work requests. ...',
|
|
69
|
+
'Messages arrive as ordinary inbound work requests.',
|
|
70
|
+
)
|
|
71
|
+
return normalized
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def scrub_value(value: Any) -> Any:
|
|
75
|
+
if isinstance(value, str):
|
|
76
|
+
return scrub_string(value)
|
|
77
|
+
if isinstance(value, list):
|
|
78
|
+
return [scrub_value(item) for item in value]
|
|
79
|
+
if isinstance(value, dict):
|
|
80
|
+
return {key: scrub_value(item) for key, item in value.items()}
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def strip_channel_instruction_attachment(attachment: dict[str, Any]) -> tuple[dict[str, Any] | None, bool]:
|
|
85
|
+
if attachment.get("type") != "mcp_instructions_delta":
|
|
86
|
+
return attachment, False
|
|
87
|
+
|
|
88
|
+
changed = False
|
|
89
|
+
normalized = dict(attachment)
|
|
90
|
+
|
|
91
|
+
added_names = normalized.get("addedNames")
|
|
92
|
+
if isinstance(added_names, list):
|
|
93
|
+
filtered_names = [name for name in added_names if not (isinstance(name, str) and name.startswith("slopmachine-"))]
|
|
94
|
+
if filtered_names != added_names:
|
|
95
|
+
normalized["addedNames"] = filtered_names
|
|
96
|
+
changed = True
|
|
97
|
+
|
|
98
|
+
added_blocks = normalized.get("addedBlocks")
|
|
99
|
+
if isinstance(added_blocks, list):
|
|
100
|
+
filtered_blocks = []
|
|
101
|
+
for block in added_blocks:
|
|
102
|
+
if not isinstance(block, str):
|
|
103
|
+
filtered_blocks.append(block)
|
|
104
|
+
continue
|
|
105
|
+
if "<channel source=" in block or "Messages arrive as <channel source=" in block or CHANNEL_INSTRUCTION_RE.search(block):
|
|
106
|
+
changed = True
|
|
107
|
+
continue
|
|
108
|
+
filtered_blocks.append(block)
|
|
109
|
+
if filtered_blocks != added_blocks:
|
|
110
|
+
normalized["addedBlocks"] = filtered_blocks
|
|
111
|
+
|
|
112
|
+
removed_names = normalized.get("removedNames")
|
|
113
|
+
if isinstance(removed_names, list) and not normalized.get("addedNames") and not normalized.get("addedBlocks") and not removed_names:
|
|
114
|
+
return None, True
|
|
115
|
+
|
|
116
|
+
if not normalized.get("addedNames") and not normalized.get("addedBlocks") and not normalized.get("removedNames"):
|
|
117
|
+
return None, True
|
|
118
|
+
|
|
119
|
+
return scrub_value(normalized), changed
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def strip_transport_metadata(record: dict[str, Any], *, keep_channel_origin: bool) -> dict[str, Any]:
|
|
123
|
+
normalized = dict(record)
|
|
124
|
+
normalized.pop("isMeta", None)
|
|
125
|
+
if not keep_channel_origin:
|
|
126
|
+
normalized.pop("origin", None)
|
|
127
|
+
return scrub_value(normalized)
|
|
128
|
+
|
|
129
|
+
|
|
52
130
|
def normalize_record(record: dict[str, Any], *, keep_channel_origin: bool) -> dict[str, Any] | None:
|
|
53
131
|
if record.get("type") == "queue-operation":
|
|
54
132
|
return None
|
|
55
133
|
|
|
134
|
+
if record.get("type") == "attachment":
|
|
135
|
+
attachment = record.get("attachment")
|
|
136
|
+
if isinstance(attachment, dict):
|
|
137
|
+
cleaned_attachment, _ = strip_channel_instruction_attachment(attachment)
|
|
138
|
+
if cleaned_attachment is None:
|
|
139
|
+
return None
|
|
140
|
+
normalized = strip_transport_metadata(record, keep_channel_origin=keep_channel_origin)
|
|
141
|
+
normalized["attachment"] = cleaned_attachment
|
|
142
|
+
return normalized
|
|
143
|
+
return strip_transport_metadata(record, keep_channel_origin=keep_channel_origin)
|
|
144
|
+
|
|
56
145
|
if record.get("type") != "user":
|
|
57
|
-
return record
|
|
146
|
+
return strip_transport_metadata(record, keep_channel_origin=keep_channel_origin)
|
|
58
147
|
|
|
59
148
|
message = record.get("message")
|
|
60
149
|
if not isinstance(message, dict):
|
|
61
|
-
return record
|
|
150
|
+
return strip_transport_metadata(record, keep_channel_origin=keep_channel_origin)
|
|
62
151
|
|
|
63
152
|
if message.get("role") != "user":
|
|
64
|
-
return record
|
|
153
|
+
return strip_transport_metadata(record, keep_channel_origin=keep_channel_origin)
|
|
65
154
|
|
|
66
155
|
content = message.get("content")
|
|
67
156
|
if not isinstance(content, str):
|
|
68
|
-
return record
|
|
157
|
+
return strip_transport_metadata(record, keep_channel_origin=keep_channel_origin)
|
|
69
158
|
|
|
70
159
|
flattened, changed = maybe_flatten_channel_content(content)
|
|
160
|
+
normalized = strip_transport_metadata(record, keep_channel_origin=keep_channel_origin)
|
|
71
161
|
if not changed:
|
|
72
|
-
return
|
|
162
|
+
return normalized
|
|
73
163
|
|
|
74
|
-
normalized = dict(record)
|
|
75
164
|
normalized_message = dict(message)
|
|
76
165
|
normalized_message["content"] = flattened
|
|
77
166
|
normalized["message"] = normalized_message
|
|
78
|
-
normalized.pop("isMeta", None)
|
|
79
|
-
|
|
80
|
-
if not keep_channel_origin:
|
|
81
|
-
normalized.pop("origin", None)
|
|
82
167
|
|
|
83
168
|
return normalized
|
|
84
169
|
|
|
85
170
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
171
|
+
def iter_input_files(input_path: Path, recursive: bool) -> Iterable[Path]:
|
|
172
|
+
if input_path.is_file():
|
|
173
|
+
yield input_path
|
|
174
|
+
return
|
|
90
175
|
|
|
91
|
-
if not input_path.
|
|
92
|
-
|
|
93
|
-
return 1
|
|
176
|
+
if not input_path.is_dir():
|
|
177
|
+
raise ValueError(f"Input path must be a file or directory: {input_path}")
|
|
94
178
|
|
|
95
|
-
|
|
179
|
+
pattern = "**/*.jsonl" if recursive else "*.jsonl"
|
|
180
|
+
for candidate in sorted(input_path.glob(pattern)):
|
|
181
|
+
if candidate.is_file():
|
|
182
|
+
yield candidate
|
|
96
183
|
|
|
184
|
+
|
|
185
|
+
def normalize_file(input_path: Path, output_path: Path, *, keep_channel_origin: bool, keep_queue_operations: bool) -> dict[str, Any]:
|
|
97
186
|
total = 0
|
|
98
187
|
queue_dropped = 0
|
|
99
188
|
channel_flattened = 0
|
|
100
189
|
|
|
190
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
|
|
101
192
|
with input_path.open("r", encoding="utf-8") as src, output_path.open("w", encoding="utf-8") as dst:
|
|
102
193
|
for line_no, line in enumerate(src, start=1):
|
|
103
194
|
stripped = line.strip()
|
|
@@ -108,17 +199,15 @@ def main() -> int:
|
|
|
108
199
|
try:
|
|
109
200
|
record = json.loads(stripped)
|
|
110
201
|
except json.JSONDecodeError as exc:
|
|
111
|
-
|
|
112
|
-
return 1
|
|
202
|
+
raise ValueError(f"Invalid JSON at line {line_no} in {input_path}: {exc}") from exc
|
|
113
203
|
|
|
114
204
|
if not isinstance(record, dict):
|
|
115
|
-
|
|
116
|
-
return 1
|
|
205
|
+
raise ValueError(f"Expected object at line {line_no} in {input_path}")
|
|
117
206
|
|
|
118
|
-
normalized = normalize_record(record, keep_channel_origin=
|
|
207
|
+
normalized = normalize_record(record, keep_channel_origin=keep_channel_origin)
|
|
119
208
|
|
|
120
209
|
if normalized is None:
|
|
121
|
-
if record.get("type") == "queue-operation" and not
|
|
210
|
+
if record.get("type") == "queue-operation" and not keep_queue_operations:
|
|
122
211
|
queue_dropped += 1
|
|
123
212
|
continue
|
|
124
213
|
|
|
@@ -137,14 +226,60 @@ def main() -> int:
|
|
|
137
226
|
|
|
138
227
|
dst.write(json.dumps(normalized, ensure_ascii=False) + "\n")
|
|
139
228
|
|
|
140
|
-
|
|
141
|
-
"ok": True,
|
|
229
|
+
return {
|
|
142
230
|
"input": str(input_path),
|
|
143
231
|
"output": str(output_path),
|
|
144
232
|
"records_seen": total,
|
|
145
233
|
"queue_operations_dropped": queue_dropped,
|
|
146
234
|
"channel_messages_flattened": channel_flattened,
|
|
147
235
|
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main() -> int:
|
|
239
|
+
args = parse_args()
|
|
240
|
+
input_path = Path(args.input)
|
|
241
|
+
output_path = Path(args.output)
|
|
242
|
+
|
|
243
|
+
if not input_path.exists():
|
|
244
|
+
print(f"Input file not found: {input_path}", file=sys.stderr)
|
|
245
|
+
return 1
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
files = list(iter_input_files(input_path, recursive=args.recursive))
|
|
249
|
+
except ValueError as exc:
|
|
250
|
+
print(str(exc), file=sys.stderr)
|
|
251
|
+
return 1
|
|
252
|
+
|
|
253
|
+
summaries = []
|
|
254
|
+
for source in files:
|
|
255
|
+
if input_path.is_dir():
|
|
256
|
+
rel = source.relative_to(input_path)
|
|
257
|
+
dest = output_path / rel
|
|
258
|
+
else:
|
|
259
|
+
dest = output_path
|
|
260
|
+
try:
|
|
261
|
+
summaries.append(
|
|
262
|
+
normalize_file(
|
|
263
|
+
source,
|
|
264
|
+
dest,
|
|
265
|
+
keep_channel_origin=args.keep_channel_origin,
|
|
266
|
+
keep_queue_operations=args.keep_queue_operations,
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
except ValueError as exc:
|
|
270
|
+
print(str(exc), file=sys.stderr)
|
|
271
|
+
return 1
|
|
272
|
+
|
|
273
|
+
summary = {
|
|
274
|
+
"ok": True,
|
|
275
|
+
"input": str(input_path),
|
|
276
|
+
"output": str(output_path),
|
|
277
|
+
"files_processed": len(summaries),
|
|
278
|
+
"records_seen": sum(item["records_seen"] for item in summaries),
|
|
279
|
+
"queue_operations_dropped": sum(item["queue_operations_dropped"] for item in summaries),
|
|
280
|
+
"channel_messages_flattened": sum(item["channel_messages_flattened"] for item in summaries),
|
|
281
|
+
"per_file": summaries,
|
|
282
|
+
}
|
|
148
283
|
print(json.dumps(summary))
|
|
149
284
|
return 0
|
|
150
285
|
|
|
@@ -9,6 +9,22 @@ import { parseArgs, emitFailure, emitSuccess, resolveClaudeSessionPath } from '.
|
|
|
9
9
|
|
|
10
10
|
const argv = parseArgs(process.argv.slice(2))
|
|
11
11
|
|
|
12
|
+
function parseSessionIds(value) {
|
|
13
|
+
return String(value || '')
|
|
14
|
+
.split(',')
|
|
15
|
+
.map((entry) => entry.trim())
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function collectRequestedSessionIds(argv) {
|
|
20
|
+
const requested = [
|
|
21
|
+
...parseSessionIds(argv['session-ids']),
|
|
22
|
+
...parseSessionIds(argv['session-id']),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
return [...new Set(requested)]
|
|
26
|
+
}
|
|
27
|
+
|
|
12
28
|
async function pathExists(targetPath) {
|
|
13
29
|
try {
|
|
14
30
|
await fs.access(targetPath)
|
|
@@ -76,42 +92,123 @@ async function createZipArchive(sourceDir, outputPath) {
|
|
|
76
92
|
|
|
77
93
|
async function normalizeClaudeJsonlFiles(projectDir) {
|
|
78
94
|
const normalizerScript = path.join(path.dirname(new URL(import.meta.url).pathname), 'normalize_claude_session.py')
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
const normalizedDir = path.join(tempRootForNormalize(projectDir), path.basename(projectDir))
|
|
96
|
+
await fs.rm(normalizedDir, { recursive: true, force: true }).catch(() => {})
|
|
97
|
+
const result = await run('python3', [normalizerScript, projectDir, '--output', normalizedDir, '--recursive'], projectDir)
|
|
98
|
+
if (result.code !== 0) {
|
|
99
|
+
throw new Error(`Failed to recursively normalize Claude session files: ${(result.stderr || result.stdout).trim()}`)
|
|
100
|
+
}
|
|
101
|
+
await fs.rm(projectDir, { recursive: true, force: true })
|
|
102
|
+
await fs.rename(normalizedDir, projectDir)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function tempRootForNormalize(projectDir) {
|
|
106
|
+
return path.join(path.dirname(projectDir), '.normalized-work')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function resolveTrackedSessionArtifacts(sessionIds, cwd) {
|
|
110
|
+
const resolved = []
|
|
111
|
+
|
|
112
|
+
for (const sessionId of sessionIds) {
|
|
113
|
+
const transcriptPath = await resolveClaudeSessionPath(sessionId, cwd)
|
|
114
|
+
if (!(await pathExists(transcriptPath))) {
|
|
115
|
+
throw new Error(`Claude transcript not found for tracked session ${sessionId}`)
|
|
89
116
|
}
|
|
90
|
-
|
|
117
|
+
|
|
118
|
+
const sourceProjectDir = path.dirname(transcriptPath)
|
|
119
|
+
const sourceSessionDir = path.join(sourceProjectDir, sessionId)
|
|
120
|
+
|
|
121
|
+
resolved.push({
|
|
122
|
+
sessionId,
|
|
123
|
+
transcriptPath,
|
|
124
|
+
sourceProjectDir,
|
|
125
|
+
sourceSessionDir,
|
|
126
|
+
hasSessionDir: await pathExists(sourceSessionDir),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return resolved
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function groupResolvedSessionsByProjectDir(resolvedSessions) {
|
|
134
|
+
const groups = new Map()
|
|
135
|
+
|
|
136
|
+
for (const session of resolvedSessions) {
|
|
137
|
+
if (!groups.has(session.sourceProjectDir)) {
|
|
138
|
+
groups.set(session.sourceProjectDir, [])
|
|
139
|
+
}
|
|
140
|
+
groups.get(session.sourceProjectDir).push(session)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return [...groups.entries()].map(([sourceProjectDir, sessions]) => ({ sourceProjectDir, sessions }))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function stageTrackedSessionArtifacts(projectGroups, tempRoot) {
|
|
147
|
+
const multiProject = projectGroups.length > 1
|
|
148
|
+
const packageRoot = path.join(
|
|
149
|
+
tempRoot,
|
|
150
|
+
multiProject ? 'claude-projects' : path.basename(projectGroups[0].sourceProjectDir),
|
|
151
|
+
)
|
|
152
|
+
const included = []
|
|
153
|
+
const projects = []
|
|
154
|
+
|
|
155
|
+
await fs.mkdir(packageRoot, { recursive: true })
|
|
156
|
+
|
|
157
|
+
for (const group of projectGroups) {
|
|
158
|
+
const groupRoot = multiProject
|
|
159
|
+
? path.join(packageRoot, path.basename(group.sourceProjectDir))
|
|
160
|
+
: packageRoot
|
|
161
|
+
|
|
162
|
+
await fs.mkdir(groupRoot, { recursive: true })
|
|
163
|
+
|
|
164
|
+
for (const session of group.sessions) {
|
|
165
|
+
const transcriptTargetPath = path.join(groupRoot, `${session.sessionId}.jsonl`)
|
|
166
|
+
await fs.copyFile(session.transcriptPath, transcriptTargetPath)
|
|
167
|
+
included.push(path.relative(packageRoot, transcriptTargetPath))
|
|
168
|
+
|
|
169
|
+
if (session.hasSessionDir) {
|
|
170
|
+
const sessionDirTargetPath = path.join(groupRoot, session.sessionId)
|
|
171
|
+
await fs.cp(session.sourceSessionDir, sessionDirTargetPath, { recursive: true })
|
|
172
|
+
included.push(path.relative(packageRoot, sessionDirTargetPath))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
projects.push({
|
|
177
|
+
source_project_dir: group.sourceProjectDir,
|
|
178
|
+
packaged_dir: path.relative(packageRoot, groupRoot) || '.',
|
|
179
|
+
session_ids: group.sessions.map((session) => session.sessionId),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
packageRoot,
|
|
185
|
+
included: included.sort((left, right) => left.localeCompare(right)),
|
|
186
|
+
projects,
|
|
91
187
|
}
|
|
92
188
|
}
|
|
93
189
|
|
|
94
190
|
try {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
throw new Error(`Claude transcript not found for session ${sessionId}`)
|
|
191
|
+
const sessionIds = collectRequestedSessionIds(argv)
|
|
192
|
+
if (sessionIds.length === 0) {
|
|
193
|
+
throw new Error('Missing --session-ids <id1,id2,...> or --session-id <id>')
|
|
99
194
|
}
|
|
100
195
|
|
|
101
|
-
const
|
|
196
|
+
const resolvedSessions = await resolveTrackedSessionArtifacts(sessionIds, argv.cwd)
|
|
197
|
+
const projectGroups = groupResolvedSessionsByProjectDir(resolvedSessions)
|
|
102
198
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'slopmachine-claude-project-'))
|
|
103
|
-
const
|
|
104
|
-
await
|
|
105
|
-
await normalizeClaudeJsonlFiles(projectDir)
|
|
106
|
-
const included = (await fs.readdir(projectDir).catch(() => [])).sort((left, right) => left.localeCompare(right))
|
|
199
|
+
const { packageRoot, included, projects } = await stageTrackedSessionArtifacts(projectGroups, tempRoot)
|
|
200
|
+
await normalizeClaudeJsonlFiles(packageRoot)
|
|
107
201
|
|
|
108
202
|
try {
|
|
109
|
-
await createZipArchive(
|
|
110
|
-
emitSuccess(
|
|
203
|
+
await createZipArchive(packageRoot, argv.output)
|
|
204
|
+
emitSuccess(sessionIds[0], {
|
|
111
205
|
output: argv.output,
|
|
112
|
-
project_dir: sourceProjectDir,
|
|
206
|
+
project_dir: projectGroups.length === 1 ? projectGroups[0].sourceProjectDir : null,
|
|
207
|
+
project_dirs: projectGroups.map((group) => group.sourceProjectDir),
|
|
113
208
|
label: argv.label || null,
|
|
209
|
+
session_ids: sessionIds,
|
|
114
210
|
included,
|
|
211
|
+
projects,
|
|
115
212
|
normalized: true,
|
|
116
213
|
})
|
|
117
214
|
} finally {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { parseArgs } from './claude_worker_common.mjs'
|
|
7
|
+
|
|
8
|
+
const argv = parseArgs(process.argv.slice(2))
|
|
9
|
+
|
|
10
|
+
function fail(message) {
|
|
11
|
+
process.stderr.write(`${message}\n`)
|
|
12
|
+
process.exit(1)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const promptFile = argv['prompt-file'] ? path.resolve(argv['prompt-file']) : null
|
|
17
|
+
const projectPromptFile = argv['project-prompt-file'] ? path.resolve(argv['project-prompt-file']) : null
|
|
18
|
+
|
|
19
|
+
if (!promptFile) {
|
|
20
|
+
fail('Missing --prompt-file')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let promptText = await fs.readFile(promptFile, 'utf8')
|
|
24
|
+
|
|
25
|
+
if (promptText.includes('{prompt}')) {
|
|
26
|
+
if (!projectPromptFile) {
|
|
27
|
+
fail('Missing --project-prompt-file for a prompt template that requires {prompt}.')
|
|
28
|
+
}
|
|
29
|
+
const projectPrompt = await fs.readFile(projectPromptFile, 'utf8')
|
|
30
|
+
promptText = promptText.replaceAll('{prompt}', projectPrompt)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const unresolvedPlaceholders = [...promptText.matchAll(/\{[a-z_]+\}/g)].map((match) => match[0])
|
|
34
|
+
if (unresolvedPlaceholders.length > 0) {
|
|
35
|
+
fail(`Unsupported unresolved prompt placeholders remain: ${[...new Set(unresolvedPlaceholders)].join(', ')}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
process.stdout.write(promptText)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
fail(error instanceof Error ? error.message : String(error))
|
|
41
|
+
}
|