@walwal-harness/cli 7.1.46 → 7.1.48

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/CHANGELOG.md CHANGED
@@ -4,7 +4,7 @@ docmeta:
4
4
  title: walwal-harness CHANGELOG
5
5
  type: output
6
6
  createdAt: 2026-05-07T00:00:00Z
7
- updatedAt: 2026-05-07T00:00:00Z
7
+ updatedAt: 2026-06-05T00:00:00Z
8
8
  source:
9
9
  producer: agent
10
10
  skillId: harness-release
@@ -31,6 +31,11 @@ docmeta:
31
31
 
32
32
  ## Unreleased
33
33
 
34
+ ## 7.1.48 — Auto-managed `.harness` `.gitignore` + runtime untrack on init (2026-06-05)
35
+ - `walwal-harness init` now writes/refreshes a marked managed block in the project-root `.gitignore` so machine-specific harness runtime/local state is never committed and never pollutes teammates' diffs. Ignored: live conductor `progress.json`/`progress.log`/`handoff.json`/`events.jsonl`, the `todos/` live queue (`state.json` + `events.jsonl`), `actions/`, `ops/`, `activity/`, `logs/`, `archive/` migration backups, and the `dashboard/` visualization tooling. Durable team knowledge stays tracked: `conventions/`, `gotchas/`, `documents/`, `memories/`, `memory.md`, `shared/`, `config.json`, `HARNESS.md`, `.bundle-version`.
36
+ - Init also best-effort `git rm --cached`s any of those runtime paths committed before the ignore block existed (files stay on disk), fixing the "`.gitignore` added but already-tracked files keep showing as changes" case.
37
+ - Canonical ignore list now lives in `assets/templates/gitignore-append.txt` as a single source of truth, consumed by both `bin/init.js` and `scripts/harness-dashboard-up.sh` (the dashboard launcher defers to init's managed block instead of appending its own narrow one).
38
+
34
39
  ## 7.1.38 — CEO approval authority for routine operations (2026-05-31)
35
40
 
36
41
  - CEO now has explicit authority to approve reversible routine operations without Owner confirmation, including mission consolidation/supersede cleanup, monitoring cadence, briefing templates, local cron/launchd/wake automation, dashboard refresh, and Telegram/dashboard notifications that use already configured credentials.
@@ -172,23 +172,32 @@ export function buildReport(target: DocTarget, state: ContractState): BuiltRepor
172
172
  const agent =
173
173
  state.agents.find((a) => a.id === target.agent) ?? state.agents[0];
174
174
 
175
+ // When opened from a timeline activity block, prefix the report with the
176
+ // moment (time + mission) the clicked block represents.
177
+ const moment =
178
+ target.at != null
179
+ ? `**Activity @ ${new Date(target.at).toLocaleString()}**` +
180
+ (target.mission ? ` · mission \`${target.mission}\`` : "") +
181
+ `\n\n---\n\n`
182
+ : "";
183
+
175
184
  if (target.type === "worker") {
176
185
  const worker = target.worker;
177
- const body = stripFrontmatter(worker.report) || NO_WORKER_SOURCE;
186
+ const body = moment + (stripFrontmatter(worker.report) || NO_WORKER_SOURCE);
178
187
  return {
179
188
  title: worker.name,
180
189
  owner: agent,
181
- kind: "Worker Brief",
190
+ kind: target.at != null ? "Worker · moment" : "Worker Brief",
182
191
  status: worker.status,
183
192
  body,
184
193
  };
185
194
  }
186
195
 
187
- const body = stripFrontmatter(agent.report) || NO_SOURCE;
196
+ const body = moment + (stripFrontmatter(agent.report) || NO_SOURCE);
188
197
  return {
189
198
  title: agent.work,
190
199
  owner: agent,
191
- kind: "CXX Document",
200
+ kind: target.at != null ? "CXX · moment" : "CXX Document",
192
201
  status: agent.status,
193
202
  body,
194
203
  };
@@ -2,11 +2,12 @@
2
2
 
3
3
  /* =============================================================
4
4
  walwal-harness · VIEW 3 — TIMELINE (cadence heatmap + swimlanes)
5
- Ported from harness/views-timeline.jsx (design handoff).
5
+ Ported from harness/views-timeline.jsx (design handoff), wired to REAL
6
+ per-lane activity marks (s.marks) instead of the prototype's random marks.
6
7
  Exports: TimelineView, Cadence
7
8
  ============================================================= */
8
9
 
9
- import React, { useRef } from "react";
10
+ import React from "react";
10
11
  import type { CSSProperties } from "react";
11
12
 
12
13
  import type {
@@ -14,6 +15,7 @@ import type {
14
15
  ContractState,
15
16
  ContractWorker,
16
17
  DocTarget,
18
+ TimelineMark,
17
19
  } from "../../lib/brick/contract";
18
20
  import { StatusDot, Label } from "./ui";
19
21
 
@@ -66,104 +68,92 @@ export interface TimelineViewProps {
66
68
  openDoc?: (target: DocTarget) => void;
67
69
  }
68
70
 
69
- type SwimMark = { ago: number; dur: number };
70
-
71
71
  type SwimRow = {
72
72
  kind: "exec" | "worker";
73
73
  id: string;
74
+ /** activity lane id: "cto" for an exec, "cto:worker-name" for a worker. */
75
+ laneId: string;
74
76
  label: string;
75
77
  hue: string;
76
78
  agent: AgentRole;
77
79
  status: string;
78
80
  progress?: number | null;
81
+ worker?: ContractWorker;
79
82
  };
80
83
 
81
84
  export function TimelineView({ s, openDoc }: TimelineViewProps) {
82
- // stable seeded marks per row id
83
- const markCache = useRef<Record<string, SwimMark[]>>({});
84
- function seedMarks(id: string, density: number): SwimMark[] {
85
- const cached = markCache.current[id];
86
- if (cached) return cached;
87
- const arr: SwimMark[] = [];
88
- const n = Math.floor(density * (4 + Math.random() * 7));
89
- for (let i = 0; i < n; i++) {
90
- arr.push({ ago: Math.random() * SPAN_MS, dur: 40000 + Math.random() * 600000 });
91
- }
92
- markCache.current[id] = arr;
93
- return arr;
94
- }
95
-
96
- // build rows: exec + its workers
97
85
  const order: AgentRole[] = ["ceo", "coo", "cto", "cqo", "cdo", "ops"];
86
+
87
+ // Build rows: each exec + the real WORKERS under it. Worker rows come from the
88
+ // activity sub-lanes ("cxx:worker-name") and the active-mission workers — NOT
89
+ // from missions — so a CXX lists the workers that actually ran under it.
98
90
  const rows: SwimRow[] = [];
99
- order.forEach((id) => {
100
- const a = s.agents.find((x) => x.id === id);
91
+ order.forEach((role) => {
92
+ const a = s.agents.find((x) => x.id === role);
101
93
  if (!a) return;
102
- rows.push({ kind: "exec", id: a.id, label: a.name, hue: a.hue, agent: a.id, status: a.status });
103
- s.workers
104
- .filter((w) => w.agent === id)
105
- .forEach((w) => {
106
- rows.push({
107
- kind: "worker",
108
- id: w.id,
109
- label: w.name,
110
- hue: a.hue,
111
- agent: a.id,
112
- status: w.status,
113
- progress: w.progress,
114
- });
115
- });
116
- });
94
+ rows.push({ kind: "exec", id: role, laneId: role, label: a.name, hue: a.hue, agent: role, status: a.status });
117
95
 
118
- // live marks from recent events (last 6 min) keyed to exec rows
119
- const liveByAgent: Record<string, number[]> = {};
120
- s.events.forEach((e) => {
121
- if (e.at == null || e.agent === "") return;
122
- const age = s.now - e.at;
123
- if (age < 6 * 60 * 1000) {
124
- (liveByAgent[e.agent] = liveByAgent[e.agent] || []).push(age);
96
+ const lanes = new Map<string, { label: string; worker?: ContractWorker }>();
97
+ for (const laneId of Object.keys(s.marks)) {
98
+ if (laneId.startsWith(role + ":")) {
99
+ lanes.set(laneId, { label: laneId.slice(role.length + 1) });
100
+ }
101
+ }
102
+ // active-mission workers (carry a report + status); key matches the sub-lane id.
103
+ for (const w of s.workers) {
104
+ if (w.agent === role) lanes.set(w.id, { label: w.name, worker: w });
105
+ }
106
+ for (const [laneId, info] of lanes) {
107
+ rows.push({
108
+ kind: "worker",
109
+ id: laneId,
110
+ laneId,
111
+ label: info.label,
112
+ hue: a.hue,
113
+ agent: role,
114
+ status: info.worker?.status ?? "idle",
115
+ progress: info.worker?.progress ?? null,
116
+ worker: info.worker,
117
+ });
125
118
  }
126
119
  });
127
120
 
128
121
  const xOf = (ago: number): number => Math.max(0, Math.min(100, (ago / SPAN_MS) * 100));
129
-
130
- // axis ticks
131
122
  const ticks: number[] = [];
132
123
  for (let h = 0; h <= 12; h += 2) ticks.push(h);
124
+ const fmtAt = (at: number): string => {
125
+ const d = new Date(at);
126
+ return Number.isFinite(d.getTime()) ? d.toTimeString().slice(0, 8) : "?";
127
+ };
133
128
 
129
+ // Label click → the whole row's report.
134
130
  const openRow = (r: SwimRow) => {
135
131
  if (!openDoc) return;
136
- if (r.kind === "exec") {
137
- openDoc({ type: "agent", agent: r.agent });
138
- return;
139
- }
140
- const worker: ContractWorker | undefined = s.workers.find((w) => w.id === r.id);
141
- if (worker) openDoc({ type: "worker", agent: r.agent, worker });
132
+ if (r.worker) openDoc({ type: "worker", agent: r.agent, worker: r.worker });
133
+ else openDoc({ type: "agent", agent: r.agent });
142
134
  };
135
+ // Block click → detail for that one activity mark (what the lane did at that moment).
136
+ const openMark = (r: SwimRow, m: TimelineMark, e: React.MouseEvent) => {
137
+ e.stopPropagation();
138
+ if (!openDoc) return;
139
+ if (r.worker) openDoc({ type: "worker", agent: r.agent, worker: r.worker, at: m.at, mission: m.mission });
140
+ else openDoc({ type: "agent", agent: r.agent, at: m.at, mission: m.mission });
141
+ };
142
+
143
+ const workerCount = rows.filter((r) => r.kind === "worker").length;
143
144
 
144
145
  return (
145
146
  <div className="timelineview">
146
147
  <Cadence cadence={s.cadence} />
147
148
  <div className="swim">
148
- <Label
149
- right={
150
- rows.filter((r) => r.kind === "worker").length +
151
- " workers · " +
152
- order.length +
153
- " execs"
154
- }
155
- >
149
+ <Label right={workerCount + " workers · " + order.length + " execs"}>
156
150
  ORCHESTRATION TIMELINE · now → −12h
157
151
  </Label>
158
152
  <div className="swim-axis">
159
153
  <div className="swim-axis-pad" />
160
154
  <div className="swim-axis-track">
161
155
  {ticks.map((h) => (
162
- <span
163
- key={h}
164
- className="swim-tick"
165
- style={{ left: (h / 12) * 100 + "%" }}
166
- >
156
+ <span key={h} className="swim-tick" style={{ left: (h / 12) * 100 + "%" }}>
167
157
  {h === 0 ? "now" : "−" + h + "h"}
168
158
  </span>
169
159
  ))}
@@ -171,8 +161,9 @@ export function TimelineView({ s, openDoc }: TimelineViewProps) {
171
161
  </div>
172
162
  <div className="swim-rows">
173
163
  {rows.map((r) => {
174
- const marks = seedMarks(r.id, r.kind === "exec" ? 1.4 : 0.8);
175
- const live = r.kind === "exec" ? liveByAgent[r.agent] || [] : [];
164
+ const marks = (s.marks[r.laneId] ?? []).filter(
165
+ (m) => s.now - m.at >= 0 && s.now - m.at <= SPAN_MS
166
+ );
176
167
  return (
177
168
  <div
178
169
  key={r.id}
@@ -205,21 +196,18 @@ export function TimelineView({ s, openDoc }: TimelineViewProps) {
205
196
  <span
206
197
  key={i}
207
198
  className="swim-block"
199
+ role="button"
200
+ title={fmtAt(m.at) + (m.mission ? " · " + m.mission : "")}
201
+ onClick={(e) => openMark(r, m, e)}
208
202
  style={{
209
- left: xOf(m.ago) + "%",
210
- width: Math.max(0.6, (m.dur / SPAN_MS) * 100) + "%",
203
+ left: xOf(s.now - m.at) + "%",
204
+ width: "9px",
205
+ cursor: "pointer",
211
206
  background: r.hue,
212
- opacity: r.kind === "exec" ? 0.85 : 0.55,
207
+ opacity: r.kind === "exec" ? 0.85 : 0.6,
213
208
  }}
214
209
  />
215
210
  ))}
216
- {live.map((age, i) => (
217
- <span
218
- key={"l" + i}
219
- className="swim-live"
220
- style={{ left: xOf(age) + "%", background: r.hue }}
221
- />
222
- ))}
223
211
  {r.kind === "worker" && r.progress != null && (
224
212
  <span
225
213
  className="swim-now-prog"
@@ -284,6 +284,17 @@ export function toContractState(
284
284
  eventsTotal: snapshot.events.length,
285
285
  };
286
286
 
287
+ // ---- timeline marks: real per-lane activity (epoch + mission) ----------
288
+ const marks: Record<string, Array<{ at: number; mission: string | null }>> = {};
289
+ for (const sample of snapshot.activitySamples) {
290
+ const at = parseTs(sample.ts);
291
+ if (at == null) continue;
292
+ (marks[sample.laneId] = marks[sample.laneId] ?? []).push({
293
+ at,
294
+ mission: sample.missionId ?? null,
295
+ });
296
+ }
297
+
287
298
  return {
288
299
  now: nowMs,
289
300
  agents,
@@ -295,6 +306,7 @@ export function toContractState(
295
306
  metrics,
296
307
  mtrend: [],
297
308
  ctrend: [],
309
+ marks,
298
310
  };
299
311
  }
300
312
 
@@ -39,8 +39,8 @@ export type AlertLevel = "stale" | "attention";
39
39
 
40
40
  /** A reference to a document the DocViewer can render. */
41
41
  export type DocTarget =
42
- | { type: "agent"; agent: AgentRole }
43
- | { type: "worker"; agent: AgentRole; worker: ContractWorker };
42
+ | { type: "agent"; agent: AgentRole; at?: number; mission?: string | null }
43
+ | { type: "worker"; agent: AgentRole; worker: ContractWorker; at?: number; mission?: string | null };
44
44
 
45
45
  /** Static, design-fixed definition of one exec agent. */
46
46
  export interface AgentDef {
@@ -173,6 +173,17 @@ export interface ContractState {
173
173
  mtrend: number[];
174
174
  /** cost sparkline samples; [] when no source. */
175
175
  ctrend: number[];
176
+ /** Real per-lane activity marks (epoch ms + mission) for the timeline
177
+ * swimlanes, keyed by laneId ("cto" exec lane or "cto:worker-name" sub-lane). */
178
+ marks: Record<string, TimelineMark[]>;
179
+ }
180
+
181
+ /** One real activity mark on a timeline lane (from an activity sample). */
182
+ export interface TimelineMark {
183
+ /** epoch ms of the activity. */
184
+ at: number;
185
+ /** mission the activity belonged to, if known. */
186
+ mission: string | null;
176
187
  }
177
188
 
178
189
  /* ---- agent (exec) definitions -----------------------------------------
@@ -1,6 +1,26 @@
1
- # walwal-harness dashboard runtime
2
- .harness/dashboard/apps/harness-dashboard/node_modules/
3
- .harness/dashboard/apps/harness-dashboard/.next/
4
- .harness/dashboard/apps/harness-dashboard/.turbo/
5
- .harness/dashboard/apps/harness-dashboard/tsconfig.tsbuildinfo
6
- .harness/dashboard/.download/
1
+ # >>> walwal-harness managed (local/runtime state — do not edit between markers) >>>
2
+ # Auto-managed by `walwal-harness init` (refreshed on every init).
3
+ # Edit the package template assets/templates/gitignore-append.txt, not this block.
4
+ #
5
+ # SHARED with teammates (kept in git): .harness/{conventions,gotchas,documents,
6
+ # memories,shared}/ .harness/{memory.md,config.json,HARNESS.md,.bundle-version}
7
+ # Everything listed below is machine-specific runtime or local tooling — never shared.
8
+ #
9
+ # Live conductor todo-queue + transition log (heartbeats churn each tick; embeds local paths)
10
+ .harness/todos/state.json
11
+ .harness/todos/events.jsonl
12
+ # Live conductor / handoff / event state (rewritten every run)
13
+ .harness/progress.json
14
+ .harness/progress.log
15
+ .harness/handoff.json
16
+ .harness/events.jsonl
17
+ # Active-sprint, service-ops, activity heatmap & logs runtime
18
+ .harness/actions/
19
+ .harness/ops/
20
+ .harness/activity/
21
+ .harness/logs/
22
+ # Migration & pre-harness backup snapshots (local-only)
23
+ .harness/archive/
24
+ # Brick Office dashboard — local visualization tool + per-env data & build artifacts
25
+ .harness/dashboard/
26
+ # <<< walwal-harness managed <<<
package/bin/init.js CHANGED
@@ -853,6 +853,102 @@ function ensureHarnessEnv() {
853
853
  log('.env: HARNESS_BASE_PORT placeholder added — CEO/CXX must choose an available value before starting services');
854
854
  }
855
855
 
856
+ // ── .gitignore management ───────────────────────────────────────────────────
857
+ // Keep the host project's root .gitignore in sync with the harness's local/runtime
858
+ // state so high-churn conductor files, backups, and dashboard tooling never get
859
+ // committed (and pollute teammates' diffs). The canonical entry list ships in
860
+ // assets/templates/gitignore-append.txt — single source of truth, also consumed by
861
+ // scripts/harness-dashboard-up.sh.
862
+ const GITIGNORE_MARKER_BEGIN = '# >>> walwal-harness managed';
863
+ const GITIGNORE_MARKER_END = '# <<< walwal-harness managed';
864
+
865
+ function readGitignoreBlock() {
866
+ const tplPath = path.join(PKG_ROOT, 'assets', 'templates', 'gitignore-append.txt');
867
+ if (!fileExists(tplPath)) return null;
868
+ const block = fs.readFileSync(tplPath, 'utf8').replace(/\s+$/, '');
869
+ return block || null;
870
+ }
871
+
872
+ // Non-comment, non-empty lines of the managed block = the local/runtime pathspecs.
873
+ function localPathsFromBlock(block) {
874
+ return block
875
+ .split('\n')
876
+ .map((l) => l.trim())
877
+ .filter((l) => l && !l.startsWith('#'));
878
+ }
879
+
880
+ // Write or refresh the marked managed block in the project-root .gitignore.
881
+ // Idempotent: an existing block is replaced in place; everything else is preserved.
882
+ function ensureProjectGitignore() {
883
+ const block = readGitignoreBlock();
884
+ if (!block) {
885
+ log(' → .gitignore: skipped (managed template missing)');
886
+ return;
887
+ }
888
+ const gitignorePath = path.join(PROJECT_ROOT, '.gitignore');
889
+ const original = fileExists(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
890
+ const lines = original.length ? original.split('\n') : [];
891
+
892
+ const beginLine = lines.findIndex((l) => l.startsWith(GITIGNORE_MARKER_BEGIN));
893
+ let next;
894
+ if (beginLine !== -1) {
895
+ let endLine = lines.findIndex((l, i) => i >= beginLine && l.startsWith(GITIGNORE_MARKER_END));
896
+ if (endLine === -1) endLine = lines.length - 1; // tolerate a truncated block
897
+ next = [...lines.slice(0, beginLine), ...block.split('\n'), ...lines.slice(endLine + 1)].join('\n');
898
+ } else {
899
+ const head = original.replace(/\n*$/, '');
900
+ next = (head ? head + '\n\n' : '') + block + '\n';
901
+ }
902
+ if (!next.endsWith('\n')) next += '\n';
903
+ if (next !== original) {
904
+ fs.writeFileSync(gitignorePath, next);
905
+ log(beginLine !== -1
906
+ ? ' → .gitignore: refreshed walwal-harness managed block'
907
+ : ' → .gitignore: added walwal-harness runtime/local ignore block');
908
+ }
909
+ }
910
+
911
+ // Best-effort: a project that committed runtime state before the ignore block existed
912
+ // keeps showing churn until those paths leave the index. Drop them from tracking
913
+ // (files stay on disk). Never fail init over git: a non-repo / missing-git / odd state
914
+ // is a quiet no-op.
915
+ function gitUntrackLocalState() {
916
+ const block = readGitignoreBlock();
917
+ if (!block) return;
918
+ try {
919
+ const inside = execSync('git rev-parse --is-inside-work-tree', {
920
+ cwd: PROJECT_ROOT, stdio: ['ignore', 'pipe', 'ignore'],
921
+ }).toString().trim();
922
+ if (inside !== 'true') return;
923
+ } catch {
924
+ return; // not a git repo / git unavailable
925
+ }
926
+
927
+ const removed = [];
928
+ for (const rel of localPathsFromBlock(block)) {
929
+ let tracked = '';
930
+ try {
931
+ tracked = execSync(`git ls-files -- ${shellQuote(rel)}`, {
932
+ cwd: PROJECT_ROOT, stdio: ['ignore', 'pipe', 'ignore'],
933
+ }).toString().trim();
934
+ } catch {
935
+ continue;
936
+ }
937
+ if (!tracked) continue;
938
+ try {
939
+ execSync(`git rm -r --cached --quiet --ignore-unmatch -- ${shellQuote(rel)}`, {
940
+ cwd: PROJECT_ROOT, stdio: ['ignore', 'ignore', 'ignore'],
941
+ });
942
+ removed.push(rel);
943
+ } catch {
944
+ // leave it tracked; non-fatal
945
+ }
946
+ }
947
+ if (removed.length) {
948
+ log(` → git: untracked ${removed.length} now-ignored runtime path(s), kept on disk (${removed.join(', ')})`);
949
+ }
950
+ }
951
+
856
952
  function ensureStructuredRuntimeFiles({ dryRun = false } = {}) {
857
953
  const todosDir = path.join(HARNESS_DIR, 'todos');
858
954
  const statePath = path.join(todosDir, 'state.json');
@@ -2621,6 +2717,8 @@ function main() {
2621
2717
  console.log('');
2622
2718
 
2623
2719
  scaffoldHarness();
2720
+ ensureProjectGitignore();
2721
+ gitUntrackLocalState();
2624
2722
  installSkills();
2625
2723
  installScripts();
2626
2724
  ensureWakeScheduler();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walwal-harness/cli",
3
- "version": "7.1.46",
3
+ "version": "7.1.48",
4
4
  "description": "Company-style AI agent harness for Claude and Codex. Installs commands, CXX agents, skills, HR-Resource hiring pool, and project-local .harness runtime state.",
5
5
  "bin": {
6
6
  "walwal-harness": "bin/init.js"
@@ -53,22 +53,27 @@ fi
53
53
 
54
54
  ensure_gitignore() {
55
55
  local gitignore="${HARNESS_ROOT}/.gitignore"
56
- local marker="# walwal-harness dashboard runtime"
56
+ # `walwal-harness init` owns the canonical managed block (covers .harness/dashboard/
57
+ # plus all other runtime/local state). If it's already present, nothing to add.
58
+ local managed_marker="# >>> walwal-harness managed"
59
+ if [[ -f "$gitignore" ]] && grep -Fq "$managed_marker" "$gitignore"; then
60
+ return
61
+ fi
62
+ # Prefer the package's canonical template (single source of truth shared with init.js);
63
+ # fall back to a dashboard-only block for older packages that lack the template.
64
+ local tpl="${PACKAGE_ROOT}/assets/templates/gitignore-append.txt"
57
65
  local block
58
- block="$(cat <<'EOF'
66
+ if [[ -f "$tpl" ]]; then
67
+ block="$(cat "$tpl")"
68
+ else
69
+ block="$(cat <<'EOF'
59
70
  # walwal-harness dashboard runtime
60
- .harness/dashboard/apps/harness-dashboard/node_modules/
61
- .harness/dashboard/apps/harness-dashboard/.next/
62
- .harness/dashboard/apps/harness-dashboard/.turbo/
63
- .harness/dashboard/apps/harness-dashboard/tsconfig.tsbuildinfo
64
- .harness/dashboard/.download/
71
+ .harness/dashboard/
65
72
  EOF
66
73
  )"
67
- if [[ -f "$gitignore" ]] && grep -Fq "$marker" "$gitignore"; then
68
- return
69
74
  fi
70
75
  mkdir -p "$(dirname "$gitignore")"
71
- if [[ -s "$gitignore" ]] && [[ "$(tail -c 1 "$gitignore" 2>/dev/null || true)" != "" ]]; then
76
+ if [[ -f "$gitignore" ]] && [[ -s "$gitignore" ]] && [[ "$(tail -c 1 "$gitignore" 2>/dev/null || true)" != "" ]]; then
72
77
  printf '\n' >> "$gitignore"
73
78
  fi
74
79
  printf '%s\n' "$block" >> "$gitignore"