@hallucination-studio/harness-engine 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -27
  3. package/bin/install.js +29 -17
  4. package/package.json +10 -4
  5. package/skills/harness-engine/SKILL.md +97 -0
  6. package/skills/harness-engine/agents/openai.yaml +4 -0
  7. package/skills/harness-engine/evals/cases.json +94 -0
  8. package/skills/harness-engine/evals/harness_engine_evals/__init__.py +1 -0
  9. package/skills/harness-engine/evals/harness_engine_evals/cases_frontend.py +211 -0
  10. package/skills/harness-engine/evals/harness_engine_evals/cases_lifecycle.py +1616 -0
  11. package/skills/harness-engine/evals/harness_engine_evals/helpers.py +155 -0
  12. package/skills/harness-engine/evals/harness_engine_evals/registry.py +55 -0
  13. package/skills/harness-engine/evals/harness_engine_evals/report.py +36 -0
  14. package/skills/harness-engine/evals/harness_engine_evals/runner.py +53 -0
  15. package/skills/harness-engine/evals/run_evals.py +14 -0
  16. package/skills/{harness-repo-bootstrap → harness-engine}/references/evaluation-loop.md +8 -2
  17. package/skills/harness-engine/references/evidence-first-evals.md +187 -0
  18. package/skills/harness-engine/references/exec-plans.md +59 -0
  19. package/skills/{harness-repo-bootstrap → harness-engine}/references/file-map.md +3 -3
  20. package/skills/{harness-repo-bootstrap → harness-engine}/references/knowledge-capture.md +2 -2
  21. package/skills/{harness-repo-bootstrap → harness-engine}/references/sop-index.md +3 -0
  22. package/skills/harness-engine/references/template-policy.md +17 -0
  23. package/skills/harness-engine/references/workflow.md +62 -0
  24. package/skills/harness-engine/scripts/harness_engine/__init__.py +1 -0
  25. package/skills/harness-engine/scripts/harness_engine/analysis.py +240 -0
  26. package/skills/harness-engine/scripts/harness_engine/checks.py +287 -0
  27. package/skills/harness-engine/scripts/harness_engine/cli.py +656 -0
  28. package/skills/harness-engine/scripts/harness_engine/common.py +977 -0
  29. package/skills/harness-engine/scripts/harness_engine/continuation.py +520 -0
  30. package/skills/harness-engine/scripts/harness_engine/git_ops.py +88 -0
  31. package/skills/harness-engine/scripts/harness_engine/knowledge.py +329 -0
  32. package/skills/harness-engine/scripts/harness_engine/plans.py +630 -0
  33. package/skills/harness-engine/scripts/harness_engine/templates.py +124 -0
  34. package/skills/harness-engine/scripts/manage_harness.py +14 -0
  35. package/skills/harness-repo-bootstrap/SKILL.md +0 -68
  36. package/skills/harness-repo-bootstrap/agents/openai.yaml +0 -4
  37. package/skills/harness-repo-bootstrap/evals/cases.json +0 -18
  38. package/skills/harness-repo-bootstrap/evals/run_evals.py +0 -337
  39. package/skills/harness-repo-bootstrap/references/exec-plans.md +0 -39
  40. package/skills/harness-repo-bootstrap/references/template-policy.md +0 -12
  41. package/skills/harness-repo-bootstrap/references/workflow.md +0 -47
  42. package/skills/harness-repo-bootstrap/scripts/manage_harness.py +0 -1181
  43. /package/skills/{harness-repo-bootstrap → harness-engine}/assets/repo-template/.keep +0 -0
  44. /package/skills/{harness-repo-bootstrap → harness-engine}/assets/sops/.keep +0 -0
  45. /package/skills/{harness-repo-bootstrap → harness-engine}/references/question-catalog.md +0 -0
@@ -0,0 +1,520 @@
1
+ from .common import *
2
+ from .plans import find_section, phase_number_from_text, plan_title, replace_section, section_key_values, slugify, mark_state_dirty, sync_state_from_markdown, load_plan_state, save_plan_state
3
+ from .templates import DOC_FILES, ensure_parent
4
+
5
+ def default_workstream_id_from_plan(plan_path, text):
6
+ source = plan_path.stem
7
+ source = re.sub(r"^\d{4}-\d{2}-\d{2}-", "", source)
8
+ source = re.sub(r"phase[-_\s]*\d+", "", source, flags=re.IGNORECASE)
9
+ source = source.strip("-_ ")
10
+ if not source:
11
+ source = plan_title(text)
12
+ source = re.sub(r"phase[-_\s]*\d+", "", source, flags=re.IGNORECASE)
13
+ return slugify(source or "workstream")
14
+
15
+
16
+ def map_legacy_phase_mode(mode):
17
+ legacy = (mode or "").strip().lower()
18
+ if legacy in {"single-phase", "single", "none", "completed"}:
19
+ return "complete"
20
+ if legacy in {"multi-phase", "phased"}:
21
+ return "continue"
22
+ if legacy == "paused":
23
+ return "pause"
24
+ if legacy == "stopped":
25
+ return "stop"
26
+ return legacy
27
+
28
+
29
+ def continuation_decision_for_plan(plan_path, text):
30
+ values = section_key_values(text, "Continuation Decision")
31
+ source = "continuation"
32
+ if values is None:
33
+ values = section_key_values(text, "Phase Continuity")
34
+ source = "phase"
35
+ detected_phase = phase_number_from_text(plan_path.stem) or phase_number_from_text(plan_title(text))
36
+ if values is None:
37
+ return {
38
+ "status": "missing",
39
+ "source": None,
40
+ "detected_phase": detected_phase,
41
+ "decision": None,
42
+ "workstream": None,
43
+ "next_target": None,
44
+ "next_action": None,
45
+ "closure_reason": None,
46
+ "resume_notes": None,
47
+ }
48
+ if source == "phase":
49
+ decision = map_legacy_phase_mode(values.get("mode", ""))
50
+ next_target = values.get("continuation")
51
+ else:
52
+ decision = values.get("decision", "").lower()
53
+ next_target = values.get("next_target") or values.get("continuation")
54
+ workstream = values.get("workstream")
55
+ next_action = values.get("next_action")
56
+ closure_reason = values.get("closure_reason")
57
+ resume_notes = values.get("resume_notes")
58
+ return {
59
+ "status": "present",
60
+ "source": source,
61
+ "detected_phase": detected_phase,
62
+ "decision": decision,
63
+ "workstream": workstream,
64
+ "next_target": next_target,
65
+ "next_action": next_action,
66
+ "closure_reason": closure_reason,
67
+ "resume_notes": resume_notes,
68
+ }
69
+
70
+
71
+ def phase_continuity_for_plan(plan_path, text):
72
+ return continuation_decision_for_plan(plan_path, text)
73
+
74
+
75
+ def is_empty_continuity_value(value):
76
+ if value is None:
77
+ return True
78
+ return value.strip().lower() in {"", "none", "pending", "unknown", "n/a", "-"}
79
+
80
+
81
+ def target_exists_for_continuation(repo, next_target, workstream):
82
+ target = next_target.split("#", 1)[0].strip()
83
+ if target in {"", "none"}:
84
+ return False
85
+ if "workstreams.md" in target:
86
+ ledger = workstreams_path(repo)
87
+ return ledger.exists() and not is_empty_continuity_value(workstream) and workstream in ledger.read_text()
88
+ return (repo / target).exists()
89
+
90
+
91
+ def deferred_target_exists(repo, next_target):
92
+ target = next_target.split("#", 1)[0].strip()
93
+ if target in {"", "none"}:
94
+ return False
95
+ if "tech-debt-tracker.md" in target:
96
+ return (repo / "docs" / "exec-plans" / "tech-debt-tracker.md").exists()
97
+ return (repo / target).exists()
98
+
99
+
100
+ def continuation_decision_issues(repo, plan_path, plan_text):
101
+ continuity = continuation_decision_for_plan(plan_path, plan_text)
102
+ if continuity["status"] == "missing":
103
+ return [
104
+ {
105
+ "severity": "error",
106
+ "code": "missing-continuation-decision",
107
+ "path": str(plan_path.relative_to(repo)),
108
+ "message": "Plan is missing a Continuation Decision section.",
109
+ }
110
+ ]
111
+ issues = []
112
+ relative_plan = str(plan_path.relative_to(repo))
113
+ decision = continuity["decision"]
114
+ if is_empty_continuity_value(decision):
115
+ issues.append(
116
+ {
117
+ "severity": "error",
118
+ "code": "continuation-decision-pending",
119
+ "path": relative_plan,
120
+ "message": "Continuation Decision must be set before plan closure.",
121
+ }
122
+ )
123
+ return issues
124
+ if decision not in {"complete", "continue", "pause", "stop", "defer"}:
125
+ issues.append(
126
+ {
127
+ "severity": "error",
128
+ "code": "invalid-continuation-decision",
129
+ "path": relative_plan,
130
+ "message": "Continuation Decision must be one of complete, continue, pause, stop, or defer.",
131
+ }
132
+ )
133
+ return issues
134
+ workstream = continuity["workstream"]
135
+ next_target = continuity["next_target"]
136
+ next_action = continuity["next_action"]
137
+ closure_reason = continuity["closure_reason"]
138
+ resume_notes = continuity["resume_notes"]
139
+ if decision == "complete":
140
+ return issues
141
+ if decision in {"continue", "pause"} and is_empty_continuity_value(workstream):
142
+ issues.append(
143
+ {
144
+ "severity": "error",
145
+ "code": "missing-workstream",
146
+ "path": relative_plan,
147
+ "message": "Continue or pause decisions must name a resumable workstream.",
148
+ }
149
+ )
150
+ if decision in {"continue", "pause"} and is_empty_continuity_value(next_action):
151
+ issues.append(
152
+ {
153
+ "severity": "error",
154
+ "code": "missing-next-action",
155
+ "path": relative_plan,
156
+ "message": "Continue or pause decisions must record a concrete next action for recovery.",
157
+ }
158
+ )
159
+ if decision == "continue":
160
+ if is_empty_continuity_value(next_target):
161
+ issues.append(
162
+ {
163
+ "severity": "error",
164
+ "code": "missing-next-target",
165
+ "path": relative_plan,
166
+ "message": "Continue decisions must point to a next plan or workstream target.",
167
+ }
168
+ )
169
+ elif not target_exists_for_continuation(repo, next_target, workstream):
170
+ issues.append(
171
+ {
172
+ "severity": "error",
173
+ "code": "missing-continuation-target",
174
+ "path": relative_plan,
175
+ "message": "Continue decision points to a missing plan or missing workstream entry.",
176
+ }
177
+ )
178
+ if decision == "pause":
179
+ if is_empty_continuity_value(closure_reason):
180
+ issues.append(
181
+ {
182
+ "severity": "error",
183
+ "code": "missing-resume-condition",
184
+ "path": relative_plan,
185
+ "message": "Pause decisions must record the condition for resuming.",
186
+ }
187
+ )
188
+ if is_empty_continuity_value(resume_notes):
189
+ issues.append(
190
+ {
191
+ "severity": "error",
192
+ "code": "missing-resume-notes",
193
+ "path": relative_plan,
194
+ "message": "Pause decisions must include resume notes.",
195
+ }
196
+ )
197
+ if decision == "stop" and is_empty_continuity_value(closure_reason):
198
+ issues.append(
199
+ {
200
+ "severity": "error",
201
+ "code": "missing-closure-reason",
202
+ "path": relative_plan,
203
+ "message": "Stop decisions must explain why the work is ending.",
204
+ }
205
+ )
206
+ if decision == "defer":
207
+ if is_empty_continuity_value(next_target):
208
+ issues.append(
209
+ {
210
+ "severity": "error",
211
+ "code": "missing-deferred-target",
212
+ "path": relative_plan,
213
+ "message": "Defer decisions must record a tech-debt or follow-up target.",
214
+ }
215
+ )
216
+ elif not deferred_target_exists(repo, next_target):
217
+ issues.append(
218
+ {
219
+ "severity": "error",
220
+ "code": "missing-deferred-target",
221
+ "path": relative_plan,
222
+ "message": "Defer decision points to a missing tech-debt or follow-up target.",
223
+ }
224
+ )
225
+ if decision in {"continue", "pause"} and not is_empty_continuity_value(workstream):
226
+ ledger = workstreams_path(repo)
227
+ if not ledger.exists() or workstream not in ledger.read_text():
228
+ issues.append(
229
+ {
230
+ "severity": "error",
231
+ "code": "missing-workstream-ledger-entry",
232
+ "path": relative_plan,
233
+ "message": "Continue or pause decision names a workstream that is not recorded in workstreams.md.",
234
+ }
235
+ )
236
+ return issues
237
+
238
+
239
+ def phase_continuity_issues(repo, plan_path, plan_text):
240
+ return continuation_decision_issues(repo, plan_path, plan_text)
241
+
242
+
243
+ def render_continuation_decision(decision, workstream, next_target, next_action, closure_reason, resume_notes):
244
+ return "\n".join(
245
+ [
246
+ f"Decision: {decision}",
247
+ f"Workstream: {workstream}",
248
+ f"Next target: {next_target}",
249
+ f"Next action: {next_action}",
250
+ f"Closure reason: {closure_reason}",
251
+ f"Resume notes: {resume_notes}",
252
+ ]
253
+ )
254
+
255
+
256
+ def plan_goal_for_workstream(plan_path, explicit_goal=None):
257
+ if explicit_goal and not is_empty_continuity_value(explicit_goal):
258
+ return explicit_goal
259
+ text = plan_path.read_text()
260
+ lines = text.splitlines()
261
+ section_index = find_section(lines, "## Goal")
262
+ if section_index is not None:
263
+ goal_lines = []
264
+ for line in lines[section_index + 1 :]:
265
+ if line.startswith("## "):
266
+ break
267
+ stripped = line.strip()
268
+ if stripped:
269
+ goal_lines.append(stripped)
270
+ if goal_lines:
271
+ return " ".join(goal_lines)
272
+ title = plan_title(text)
273
+ return title or plan_path.stem
274
+
275
+
276
+ def continuation_command_issues(repo, relative_plan, decision, workstream, next_target, next_action, closure_reason, resume_notes):
277
+ issues = []
278
+ decision = (decision or "").lower()
279
+ if decision not in {"complete", "continue", "pause", "stop", "defer"}:
280
+ issues.append(
281
+ {
282
+ "severity": "error",
283
+ "code": "invalid-continuation-decision",
284
+ "path": relative_plan,
285
+ "message": "Continuation Decision must be one of complete, continue, pause, stop, or defer.",
286
+ }
287
+ )
288
+ return issues
289
+ if decision in {"continue", "pause"} and is_empty_continuity_value(workstream):
290
+ issues.append(
291
+ {
292
+ "severity": "error",
293
+ "code": "missing-workstream",
294
+ "path": relative_plan,
295
+ "message": "Continue or pause decisions must name a resumable workstream.",
296
+ }
297
+ )
298
+ if decision in {"continue", "pause"} and is_empty_continuity_value(next_action):
299
+ issues.append(
300
+ {
301
+ "severity": "error",
302
+ "code": "missing-next-action",
303
+ "path": relative_plan,
304
+ "message": "Continue or pause decisions must record a concrete next action for recovery.",
305
+ }
306
+ )
307
+ if decision == "continue" and is_empty_continuity_value(next_target):
308
+ issues.append(
309
+ {
310
+ "severity": "error",
311
+ "code": "missing-next-target",
312
+ "path": relative_plan,
313
+ "message": "Continue decisions must point to a next plan or workstream target.",
314
+ }
315
+ )
316
+ if decision == "pause":
317
+ if is_empty_continuity_value(closure_reason):
318
+ issues.append(
319
+ {
320
+ "severity": "error",
321
+ "code": "missing-resume-condition",
322
+ "path": relative_plan,
323
+ "message": "Pause decisions must record the condition for resuming.",
324
+ }
325
+ )
326
+ if is_empty_continuity_value(resume_notes):
327
+ issues.append(
328
+ {
329
+ "severity": "error",
330
+ "code": "missing-resume-notes",
331
+ "path": relative_plan,
332
+ "message": "Pause decisions must include resume notes.",
333
+ }
334
+ )
335
+ if decision == "stop" and is_empty_continuity_value(closure_reason):
336
+ issues.append(
337
+ {
338
+ "severity": "error",
339
+ "code": "missing-closure-reason",
340
+ "path": relative_plan,
341
+ "message": "Stop decisions must explain why the work is ending.",
342
+ }
343
+ )
344
+ if decision == "defer":
345
+ if is_empty_continuity_value(next_target):
346
+ issues.append(
347
+ {
348
+ "severity": "error",
349
+ "code": "missing-deferred-target",
350
+ "path": relative_plan,
351
+ "message": "Defer decisions must record a tech-debt or follow-up target.",
352
+ }
353
+ )
354
+ elif not deferred_target_exists(repo, next_target):
355
+ issues.append(
356
+ {
357
+ "severity": "error",
358
+ "code": "missing-deferred-target",
359
+ "path": relative_plan,
360
+ "message": "Defer decision points to a missing tech-debt or follow-up target.",
361
+ }
362
+ )
363
+ return issues
364
+
365
+
366
+ def update_continuation_decision(plan_path, decision, workstream, next_target, next_action, closure_reason, resume_notes):
367
+ text = plan_path.read_text()
368
+ decision = decision.lower()
369
+ resolved_workstream = workstream or (
370
+ default_workstream_id_from_plan(plan_path, text) if decision in {"continue", "pause"} else "none"
371
+ )
372
+ body = render_continuation_decision(
373
+ decision,
374
+ resolved_workstream,
375
+ next_target,
376
+ next_action,
377
+ closure_reason,
378
+ resume_notes,
379
+ )
380
+ if find_section(text.splitlines(), "## Continuation Decision") is None and find_section(text.splitlines(), "## Phase Continuity") is not None:
381
+ updated = replace_section(text, "Phase Continuity", body)
382
+ updated = updated.replace("## Phase Continuity", "## Continuation Decision", 1)
383
+ else:
384
+ updated = replace_section(text, "Continuation Decision", body)
385
+ plan_path.write_text(updated)
386
+ return {
387
+ "status": "updated",
388
+ "decision": decision,
389
+ "workstream": resolved_workstream,
390
+ "next_target": next_target,
391
+ "next_action": next_action,
392
+ }
393
+
394
+
395
+ def update_phase_continuity(plan_path, mode, workstream, current_phase, next_phase, continuation, next_action, closure_reason, resume_notes):
396
+ return update_continuation_decision(
397
+ plan_path,
398
+ map_legacy_phase_mode(mode),
399
+ workstream,
400
+ continuation,
401
+ next_action,
402
+ closure_reason,
403
+ resume_notes,
404
+ )
405
+
406
+
407
+ def workstreams_path(repo):
408
+ return repo / "docs" / "exec-plans" / "workstreams.md"
409
+
410
+
411
+ def workstream_table_insert_index(lines):
412
+ index_heading = find_section(lines, "## Index")
413
+ if index_heading is None:
414
+ return len(lines)
415
+ index = index_heading + 1
416
+ while index < len(lines) and lines[index].strip() == "":
417
+ index += 1
418
+ while index < len(lines) and not lines[index].startswith("| ID |"):
419
+ if lines[index].startswith("## "):
420
+ return index
421
+ index += 1
422
+ if index >= len(lines):
423
+ return index_heading + 1
424
+ index += 1
425
+ if index < len(lines) and lines[index].startswith("| ---"):
426
+ index += 1
427
+ while index < len(lines) and lines[index].startswith("|"):
428
+ index += 1
429
+ return index
430
+
431
+
432
+ def append_workstream_entry(repo, workstream_id, status, current_plan, last_completed_plan, next_action, goal, resume_notes):
433
+ target = workstreams_path(repo)
434
+ ensure_parent(target)
435
+ if not target.exists():
436
+ target.write_text(DOC_FILES["docs/exec-plans/workstreams.md"].format(marker=MANAGED_MARKER))
437
+ text = target.read_text()
438
+ today = datetime.now(UTC).strftime("%Y-%m-%d")
439
+ row = (
440
+ f"| {workstream_id} | {status} | {current_plan or 'none'} | "
441
+ f"{last_completed_plan or 'none'} | {next_action or 'none'} | {today} |"
442
+ )
443
+ lines = text.splitlines()
444
+ replaced = False
445
+ updated_lines = []
446
+ for line in lines:
447
+ if line.startswith(f"| {workstream_id} |"):
448
+ updated_lines.append(row)
449
+ replaced = True
450
+ else:
451
+ updated_lines.append(line)
452
+ if not replaced:
453
+ insert_index = workstream_table_insert_index(updated_lines)
454
+ updated_lines.insert(insert_index, row)
455
+ detail = (
456
+ f"Status: {status}\n"
457
+ f"Goal: {goal or 'Record the durable goal for this workstream.'}\n"
458
+ f"Current plan: {current_plan or 'none'}\n"
459
+ f"Last completed plan: {last_completed_plan or 'none'}\n"
460
+ f"Next action: {next_action or 'none'}\n"
461
+ f"Resume notes: {resume_notes or 'Read the current or last completed plan before continuing.'}\n"
462
+ f"Last updated: {today}"
463
+ )
464
+ updated_text = "\n".join(updated_lines).rstrip() + "\n"
465
+ updated_text = replace_section(updated_text, workstream_id, detail)
466
+ target.write_text(updated_text)
467
+ return target
468
+
469
+
470
+ def update_workstreams_after_plan_close(repo, active_relative_plan, completed_relative_plan):
471
+ target = workstreams_path(repo)
472
+ if not target.exists():
473
+ return
474
+ lines = target.read_text().splitlines()
475
+ updated = []
476
+ current_plan_was_closed = False
477
+ for line in lines:
478
+ stripped = line.strip()
479
+ if stripped.startswith("|") and not stripped.startswith("| ---") and not stripped.startswith("| ID |"):
480
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
481
+ if len(cells) == 6:
482
+ if cells[2] == active_relative_plan:
483
+ cells[2] = "none"
484
+ if cells[3] == "none":
485
+ cells[3] = completed_relative_plan
486
+ if cells[3] == active_relative_plan:
487
+ cells[3] = completed_relative_plan
488
+ updated.append("| " + " | ".join(cells) + " |")
489
+ continue
490
+ if line == f"Current plan: {active_relative_plan}":
491
+ updated.append("Current plan: none")
492
+ current_plan_was_closed = True
493
+ continue
494
+ if line == f"Last completed plan: {active_relative_plan}":
495
+ updated.append(f"Last completed plan: {completed_relative_plan}")
496
+ current_plan_was_closed = False
497
+ continue
498
+ if current_plan_was_closed and line == "Last completed plan: none":
499
+ updated.append(f"Last completed plan: {completed_relative_plan}")
500
+ current_plan_was_closed = False
501
+ continue
502
+ updated.append(line)
503
+ if line.startswith("## "):
504
+ current_plan_was_closed = False
505
+ target.write_text("\n".join(updated).rstrip() + "\n")
506
+
507
+
508
+ def assert_phase_continuity_closed(repo, plan_path, plan_text):
509
+ issues = continuation_decision_issues(repo, plan_path, plan_text)
510
+ if issues:
511
+ messages = "\n".join(f"- {issue['code']}: {issue['message']}" for issue in issues)
512
+ raise PlanCloseError(
513
+ "continuation-decision-incomplete",
514
+ "Cannot close plan until the continuation decision is recorded:\n"
515
+ + messages
516
+ + "\nRecord a continuation decision before closing.",
517
+ {"issues": issues},
518
+ )
519
+
520
+
@@ -0,0 +1,88 @@
1
+ from .common import *
2
+ from .templates import ensure_parent
3
+
4
+ def ensure_gitignore(repo):
5
+ path = repo / ".gitignore"
6
+ existing = path.read_text() if path.exists() else ""
7
+ block_lines = [GITIGNORE_BLOCK_START, *GITIGNORE_ENTRIES, GITIGNORE_BLOCK_END]
8
+ block = "\n".join(block_lines)
9
+ pattern = re.compile(
10
+ rf"(^|\n){re.escape(GITIGNORE_BLOCK_START)}\n.*?\n{re.escape(GITIGNORE_BLOCK_END)}(?=\n|$)",
11
+ flags=re.DOTALL,
12
+ )
13
+ if pattern.search(existing):
14
+ updated = pattern.sub(lambda match: match.group(1) + block, existing)
15
+ else:
16
+ prefix = existing.rstrip()
17
+ updated = f"{prefix}\n\n{block}" if prefix else block
18
+ updated = updated.rstrip() + "\n"
19
+ changed = updated != existing
20
+ if changed:
21
+ path.write_text(updated)
22
+ return {
23
+ "path": ".gitignore",
24
+ "updated": changed,
25
+ "entries": GITIGNORE_ENTRIES,
26
+ }
27
+
28
+
29
+ def clean_init_state(repo):
30
+ cleaned = []
31
+ for relative_dir in CLEAN_INIT_DIRS:
32
+ root = repo / relative_dir
33
+ if not root.exists():
34
+ continue
35
+ for path in sorted(root.rglob("*"), reverse=True):
36
+ if path.is_file() or path.is_symlink():
37
+ cleaned.append(str(path.relative_to(repo)))
38
+ path.unlink()
39
+ elif path.is_dir():
40
+ try:
41
+ path.rmdir()
42
+ except OSError:
43
+ pass
44
+ return cleaned
45
+
46
+
47
+ def git_tracked_harness_runtime_files(repo, roots=None):
48
+ if not (repo / ".git").exists():
49
+ return []
50
+ roots = roots or GIT_CLEAN_PATHS
51
+ result = subprocess.run(
52
+ ["git", "-C", str(repo), "ls-files", *roots],
53
+ text=True,
54
+ capture_output=True,
55
+ check=False,
56
+ )
57
+ if result.returncode != 0:
58
+ raise RuntimeError(result.stderr.strip() or "git ls-files failed")
59
+ return [line for line in result.stdout.splitlines() if line.strip()]
60
+
61
+
62
+ def git_untrack_files(repo, paths):
63
+ if not paths:
64
+ return []
65
+ result = subprocess.run(
66
+ ["git", "-C", str(repo), "rm", "-r", "--cached", "--", *paths],
67
+ text=True,
68
+ capture_output=True,
69
+ check=False,
70
+ )
71
+ if result.returncode != 0:
72
+ raise RuntimeError(result.stderr.strip() or "git rm --cached failed")
73
+ return paths
74
+
75
+
76
+ def git_add_paths(repo, paths):
77
+ if not paths:
78
+ return []
79
+ result = subprocess.run(
80
+ ["git", "-C", str(repo), "add", "--", *paths],
81
+ text=True,
82
+ capture_output=True,
83
+ check=False,
84
+ )
85
+ if result.returncode != 0:
86
+ raise RuntimeError(result.stderr.strip() or "git add failed")
87
+ return paths
88
+