siesa-agents 2.1.80 → 2.1.82

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.
@@ -0,0 +1,145 @@
1
+ ---
2
+ name: sa-init-devops
3
+ description: 'Bootstrap the DevOps workspace and skills in Siesa-Agents in one shot: clones architecture-sa-devops into `_siesa-agents/devops/`, detects conflicts between local and upstream copies of the `sa-*` DevOps skills and the deploy workflows (asks the user via AskUserQuestion which side to keep when there are diffs), then MOVES the chosen versions into `.claude/skills/` (and `claude/skills/`) so `/sa-aplicar`, `/sa-nuevo-servicio`, `/sa-auditar-servicio`, etc. become immediately invocable, MOVES the workflows into `.github/workflows/`, deletes the now-empty `.claude/` and `.github/` directories from the clone, then mirrors the cleaned tree to `siesa-agents/devops/`. Use whenever the user wants to set up the DevOps workspace for the first time, refresh it after upstream changes, mentions needing terraform/k8s/environments files, or when any `sa-*` deploy skill is missing from Claude Code.'
4
+ ---
5
+
6
+ # Init DevOps — clone the workspace + materialize the DevOps skills
7
+
8
+ **Goal:** In a single invocation, populate Siesa-Agents with everything an engineer needs to run the Finance DevOps skills. After a successful run the layout is:
9
+
10
+ | Location | Contents |
11
+ |---|---|
12
+ | `_siesa-agents/devops/` | Git working tree cloned from `architecture-sa-devops`. **Only** the deployment workspace: `terraform/`, `k8s/`, `environments/`, `scripts/`, `cicd-templates/`, `docs/`, `CLAUDE.md`, `README.md`, `.gitignore`. The `.claude/` and `.github/` directories from upstream are wiped from the clone after their contents are moved to Siesa-Agents level. |
13
+ | `siesa-agents/devops/` | Flat snapshot mirror of the cleaned clone (no `.git/`, no `.claude/`, no `.github/`). The npm-publishable copy. |
14
+ | `.claude/skills/sa-*/SKILL.md` | The 8 DevOps skills moved out of the clone: `sa-aplicar`, `sa-auditar-servicio`, `sa-nueva-transversal`, `sa-nuevo-ambiente`, `sa-nuevo-servicio`, `sa-onboard-db`, `sa-registrar-permisos`, `sa-agent-sre-sentinel`. |
15
+ | `claude/skills/sa-*/SKILL.md` | Same skills, in the npm mirror. |
16
+ | `.github/workflows/*.yml` | The 4 deploy workflow files moved out of the clone (`infra-pipeline-dev/qa/shared.yml`, `reconcile-geographic-projections.yml`). **These do not fire from Siesa-Agents** — gitignored, for visibility only. They fire from `architecture-sa-devops` where they originally live. |
17
+
18
+ **Why this matters:** Without this skill, Siesa-Agents is missing every Finance DevOps capability. The DevOps skills cannot live statically in this repo because they evolve with the deploy workspace (`environments/`, `terraform/`, `k8s/`) — keeping them in sync would require a PR every time Finance bumps the workspace. Instead the upstream `architecture-sa-devops` repo is the single source of truth, and this skill pulls everything down on demand.
19
+
20
+ ## When to use this skill
21
+
22
+ Trigger immediately when the user:
23
+
24
+ - Says "init devops", "setup devops", "clone architecture-sa-devops", "trae el workspace de despliegue", or similar
25
+ - Tries to invoke any deploy skill (`/sa-aplicar`, `/sa-nuevo-servicio`, etc.) and Claude Code reports the skill is not available
26
+ - Tries to invoke a deploy skill and it complains about missing paths (`environments/shared.yaml`, `terraform/environments/`, `k8s/overlays/`, etc.)
27
+ - Mentions wanting to refresh / pull latest from `architecture-sa-devops`
28
+ - Has just cloned Siesa-Agents and is preparing to use the deploy skills
29
+
30
+ Do **not** trigger for: editing the deploy skills themselves (those edits must go to `architecture-sa-devops`), modifying terraform/k8s contents (those go to `architecture-sa-devops` too), or anything unrelated to bootstrapping.
31
+
32
+ ## Prerequisites
33
+
34
+ - `git` is on the PATH (`git --version` works).
35
+ - The user has network access to GitHub.com.
36
+ - The helper script lives at `_siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js` (source of truth) and is mirrored to `siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js` (npm copy). Prefer the source-of-truth path when both exist.
37
+
38
+ If the script is missing or `git` is unavailable, stop and tell the user — do not improvise the work inline. The script handles all the edge cases (existing clone with wrong remote, partial state, conflicting local edits, skill/workflow copy overwrites) that an inline shell sequence would miss.
39
+
40
+ ## Workflow
41
+
42
+ Two-phase orchestration. The helper does an initial conflict-detection pass; if any local file differs from upstream, the helper aborts and the model asks the user how to resolve, then re-runs with a policy flag.
43
+
44
+ ### Step 1 — Initial run
45
+
46
+ From the project root (or any subdirectory of it — the script walks up to find both `_siesa-agents/` and `siesa-agents/`):
47
+
48
+ ```bash
49
+ node _siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js
50
+ ```
51
+
52
+ Default mode does:
53
+
54
+ 1. **Clone or update.** `git clone` (if `_siesa-agents/devops/` does not exist) **or** `git checkout HEAD -- .claude .github && git fetch && git checkout main && git pull --ff-only` (if it does). The `git checkout HEAD -- .claude .github` step restores files that the previous run wiped so the pull doesn't fail on "uncommitted deletions".
55
+ 2. **Dry-run conflict detection.** For each upstream `.claude/skills/sa-*/SKILL.md` and `.github/workflows/*.yml`, compare to the local copy in Siesa-Agents (`.claude/skills/`, `claude/skills/`, and `.github/workflows/`). The bootstrap skill `sa-init-devops` is always excluded from this comparison.
56
+ 3. **If any local file differs from upstream → abort.** Exit 4 with `status: "conflicts"` and lists `skill_conflicts` / `workflow_conflicts`. Nothing is mutated. The model proceeds to Step 2.
57
+ 4. **If no conflicts → apply.** Copy the upstream files into Siesa-Agents (creating new files, leaving identical ones alone), then delete `.claude/` and `.github/` from the clone working tree, then mirror the cleaned tree to `siesa-agents/devops/`. Exit 0.
58
+
59
+ ### Step 2 — Handle conflicts (only when `status: "conflicts"`)
60
+
61
+ When the JSON output has `status: "conflicts"`, **ask the user which side to keep** before any local file is changed. Use `AskUserQuestion` with the list of conflicting items from `skill_conflicts` and `workflow_conflicts`.
62
+
63
+ For most cases, one question with three options is enough:
64
+
65
+ ```
66
+ question: "Estos N archivos locales difieren del upstream architecture-sa-devops:
67
+ <list with skill_conflicts + workflow_conflicts>
68
+ ¿Cómo resuelvo?"
69
+ options:
70
+ - "Take all from upstream" (overwrite all conflicts with upstream version)
71
+ - "Keep all local versions" (preserve every local edit)
72
+ - "Resolve per file" (only if user wants per-item granularity)
73
+ ```
74
+
75
+ If the user picks **"Take all from upstream"**, re-run with `--take-upstream`:
76
+
77
+ ```bash
78
+ node _siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js --take-upstream
79
+ ```
80
+
81
+ If the user picks **"Keep all local versions"**, re-run with `--keep-local`:
82
+
83
+ ```bash
84
+ node _siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js --keep-local
85
+ ```
86
+
87
+ If the user picks **"Resolve per file"**, ask one more question per conflicting item (with `AskUserQuestion`, max 4 items per call — chunk if needed) collecting which ones to keep local, then re-run with `--keep-local=<csv>`:
88
+
89
+ ```bash
90
+ node _siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js --keep-local=sa-aplicar,infra-pipeline-qa.yml
91
+ ```
92
+
93
+ The named items keep their local version; everything else takes upstream.
94
+
95
+ ### Step 3 — Interpret the final JSON status
96
+
97
+ After the (possibly second) successful invocation, read the `status` field:
98
+
99
+ - **`status: "cloned"`** — Fresh clone. Tell the user: "Cloned `architecture-sa-devops`. Created `<N>` skills + `<M>` workflows in Siesa-Agents. The deploy skills (`/sa-aplicar`, `/sa-nuevo-servicio`, etc.) are now available in Claude Code."
100
+ - **`status: "updated"`** — Existing clone pulled new commits. Report `before_sha..after_sha` and the per-category counts (`skills_created`, `skills_overwritten`, `skills_kept_local`, `skills_unchanged`, same for workflows).
101
+ - **`status: "already-cloned"`** — Local clone was already at the latest HEAD; no upstream change. The counts reflect what happened in the apply step (likely all `unchanged` if everything matched).
102
+ - **`status: "conflicts"`** — Should only appear on the FIRST run when conflicts exist; never on a re-run with a policy flag. If you see this twice, surface it as a bug.
103
+ - **`status: "invalid-source"`** — `_siesa-agents/devops/` exists but is not a git working tree. Surface the message verbatim and stop. Do not try to delete the directory automatically.
104
+ - **`status: "wrong-remote"`** — Same directory is a clone of a different repo. Surface the message and stop.
105
+ - **`status: "clone-failed"` / `"fetch-failed"` / `"pull-failed"` / `"checkout-failed"`** — Git operation failed. Surface the underlying git error verbatim. Common causes: no network, branch diverged, local edits blocking ff-pull.
106
+
107
+ ### Step 3 — Remind the user about cwd for the deploy skills
108
+
109
+ After a successful run (any of `cloned`, `updated`, `already-cloned`), remind the user once:
110
+
111
+ > Las skills de despliegue (`/sa-aplicar`, `/sa-nuevo-servicio`, etc.) asumen que el cwd está dentro de `_siesa-agents/devops/`. Cambia de directorio (`cd _siesa-agents/devops/`) antes de invocarlas, para que las rutas relativas (`environments/`, `terraform/`, `k8s/`) se resuelvan correctamente.
112
+
113
+ If the user invokes one of those skills from elsewhere, the relative paths in their workflows won't resolve and they'll see confusing "file not found" errors.
114
+
115
+ ## Optional flags
116
+
117
+ - `--check` — Read-only. Clones/pulls if needed, then reports current state plus a dry-run conflict list. Useful to preview what a real run would surface.
118
+ - `--mirror-only` — Skip the git + move steps; rebuild `siesa-agents/devops/` from the existing clone. Useful when only the mirror is out of sync.
119
+ - `--take-upstream` — Resolve all conflicts in favor of upstream (overwrite every conflicting local file). Use after the user confirms they want the upstream versions.
120
+ - `--keep-local` — Keep every local file as-is; only create files that are entirely new in upstream. Use when the user wants to preserve all local edits.
121
+ - `--keep-local=<csv>` — Keep these specific items local (comma-separated names matching `skill_conflicts` / `workflow_conflicts` entries); overwrite all other conflicts with upstream. Example: `--keep-local=sa-aplicar,infra-pipeline-qa.yml`.
122
+
123
+ The three policy flags are mutually exclusive; passing more than one returns exit code 1.
124
+
125
+ ## Edge cases
126
+
127
+ - **Skill conflicts.** The default run aborts on any conflict (local file differs from upstream) and lists them in `skill_conflicts` / `workflow_conflicts`. The model then asks the user with `AskUserQuestion` and re-runs with `--take-upstream`, `--keep-local`, or `--keep-local=<csv>`. Engineers who want their custom edits to survive automatic refresh should fork `architecture-sa-devops` and update `REMOTE_URL` at the top of the helper script — that keeps their fork as the upstream source of truth.
128
+ - **The bootstrap skill is never overwritten.** `sa-init-devops/` is explicitly excluded from the copy loop in the helper, so re-running the skill cannot trash itself.
129
+ - **The clone has 12 "deleted" files in `git status` after every run.** Expected. The script moves `.claude/skills/sa-*/SKILL.md` and `.github/workflows/*.yml` out of the clone and deletes those directories, but the files are still tracked in upstream HEAD. The next run restores them via `git checkout HEAD -- .claude .github` before pulling, then moves and deletes them again. Engineers should never `git add` or `git commit` those deletions inside `_siesa-agents/devops/` — they are intentional working-tree-only state.
130
+ - **Workflows in Siesa-Agents `.github/workflows/`** — moved for inspection but gitignored (see `.gitignore`), so engineers can read them locally but they never fire on Siesa-Agents PRs. The same workflows fire on PRs / pushes to `architecture-sa-devops`, which is where they live.
131
+ - **`_siesa-agents/devops/` exists from a previous run with local edits to non-managed paths.** The restore step touches only `.claude` and `.github`; edits to `environments/`, `terraform/`, `k8s/`, etc. survive the restore step but `git pull --ff-only` will fail if they conflict with upstream. The script surfaces the git error verbatim. The user should commit, stash, or discard their local changes and re-run.
132
+ - **The directory exists but isn't a clone of `architecture-sa-devops`.** The script reports `invalid-source` or `wrong-remote` and refuses to delete. The user must move it aside manually.
133
+ - **The mirror or local skills/workflows have manual edits.** The script overwrites them on every run — that is by design. If you need to preserve edits, make them in the upstream `architecture-sa-devops` repo, not locally.
134
+ - **No `git` on PATH.** The first `tryGit` call fails; the script surfaces a `clone-failed` or `fetch-failed` status. Tell the user to install git or add it to PATH.
135
+
136
+ ## Upstream URL
137
+
138
+ The script currently points at `https://github.com/ssancheze912/architecture-sa-devops.git` as a temporary location while SiesaTeams org-create permissions are being arranged. When the repo is transferred to `https://github.com/SiesaTeams/architecture-sa-devops.git`, update `REMOTE_URL` at the top of the helper script and ship the change.
139
+
140
+ ## Reminders
141
+
142
+ - Step 1 is always safe to re-run. Idempotency is built into the script — on `already-cloned` it does nothing destructive other than refresh the mirror and re-copy the skills/workflows from upstream (which is the intended behavior — keeping local in sync with upstream).
143
+ - Never delete `_siesa-agents/devops/` from this skill. The user owns that directory once cloned; they may have in-progress edits.
144
+ - Do not modify the DevOps skills themselves from this skill. If a deploy skill's behavior needs to change, that is a PR to `architecture-sa-devops`, not a local edit (which would be wiped on the next run).
145
+ - The script's JSON output is the contract. If a future field appears (e.g. a new status), surface it to the user rather than guessing what it means.
@@ -73,7 +73,7 @@ Parse the JSON object printed to stdout. The `status` field selects the next bra
73
73
 
74
74
  Proceed straight to Step 2 with `proposed_slug` and `recommended_dir`. Do **not** ask the user to confirm — the user has already accepted the auto-detection policy by invoking this skill.
75
75
 
76
- **`status: "single-repo"`** The repo name has no `docs`/`backend`/`frontend` segment, so the git remote already produces an unambiguous `project_id`. Stop and tell the user no `.siesa-project` is needed (mention the `fallback_project_id` value so they know what GCP will see). Do not write anything. If they later say "no, write one anyway with slug X", run Step 2 with that explicit slug and the directory they specify.
76
+ Single-repo projects also return `status: "ready"` (with `convention_match: false`). The proposed slug is the repo name lowercased and sanitized to kebab-case (e.g. `Siesa-Agents` `siesa-agents`) and `recommended_dir` is the git root itself. Proceed to Step 2 the same way no separate branch.
77
77
 
78
78
  **`status: "already-configured"`** — A `.siesa-project` exists above the current git root. Stop and report `existing_siesa_project.path` and `existing_siesa_project.value` to the user. Do not overwrite implicitly. If the user replies that they want it changed, run `--write` with `--force`, the new slug, and the same directory.
79
79
 
@@ -95,7 +95,9 @@ Parse the JSON response:
95
95
  - **`status: "exists"`** — A file is already there with a different value (the detect step missed it because the user pointed at a non-default dir, or a race). Show `current_value` vs `requested_slug` and ask the user whether to overwrite. If yes, re-run with `--force`.
96
96
  - **`status: "error"`** — Surface the error message verbatim and stop.
97
97
 
98
- For the `single-repo`, `already-configured` and `no-remote` branches where the user explicitly opts into writing, gather slug + directory inline and run the same `--write` command.
98
+ For the `already-configured` and `no-remote` branches where the user explicitly opts into writing, gather slug + directory inline and run the same `--write` command.
99
+
100
+ The file body written by `--write` is always `project_id=<slug>\n` (key=value form). `sa-emit.js` accepts both this format and a legacy bare-slug line for backward compatibility, so existing `.siesa-project` files keep working.
99
101
 
100
102
  ### Step 3 — Verify (optional)
101
103
 
@@ -129,7 +131,7 @@ business-<module>-<role>-<rest>
129
131
 
130
132
  The logical slug collapses `<role>` away: `business-pos-backend-pdv` → `pos-pdv`.
131
133
 
132
- For repo names that do not contain `docs`/`backend`/`frontend`, the script returns `single-repo` and no `.siesa-project` is needed.
134
+ For repo names that do not contain `docs`/`backend`/`frontend`, the script still returns `status: "ready"` but with `convention_match: false`. The slug is derived from the repo name (lowercased, sanitized to kebab-case) and the file is written at the git root of that single repo.
133
135
 
134
136
  ## Examples
135
137
 
@@ -157,11 +159,11 @@ Model: <runs sa-init-env.js — status "created">
157
159
 
158
160
  ```
159
161
  User: Set up .siesa-project for Siesa-Agents.
160
- Model: <runs --detect from Siesa-Agents/>
161
- Repo name "Siesa-Agents" has no docs/backend/frontend segment, so the git remote already produces an unambiguous project_id ("SiesaTeams/Siesa-Agents"). No .siesa-project is needed.
162
- Want to set a custom slug anyway?
163
- User: no, thanks
164
- Model: Nothing written. Existing fallback (SiesaTeams/Siesa-Agents) will continue to be reported.
162
+ Model: <runs sa-init-env.js silent>
163
+ <runs --detect from Siesa-Agents/>
164
+ <reads proposed_slug="siesa-agents", recommended_dir=C:\…\Siesa-Agents, convention_match=false>
165
+ <runs --write --slug siesa-agents --dir C:\…\Siesa-Agents>
166
+ Wrote C:\…\Siesa-Agents\.siesa-project (project_id=siesa-agents). sa-emit.js will now report project_id="siesa-agents" instead of "SiesaTeams/Siesa-Agents".
165
167
  ```
166
168
 
167
169
  **Example 3 — Already configured**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siesa-agents",
3
- "version": "2.1.80",
3
+ "version": "2.1.82",
4
4
  "description": "Paquete para instalar y configurar agentes SIESA en tu proyecto",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -134,17 +134,38 @@ const stateDir = path.join(os.homedir(), '.claude', 'observability')
134
134
  const epicIdMatch = story.match(/^(\d+)-/)
135
135
  const epicId = epicIdMatch ? epicIdMatch[1] : 'unknown'
136
136
 
137
+ function parseSiesaProject(content) {
138
+ // Preferred format: a single `project_id=<slug>` line. For backward compatibility a bare
139
+ // slug on its own line is also accepted. `#` introduces a comment. Returns the resolved
140
+ // slug, or null if nothing usable was found.
141
+ if (!content) return null
142
+ let firstBare = null
143
+ for (const raw of content.split(/\r?\n/)) {
144
+ const line = raw.trim()
145
+ if (!line || line.startsWith('#')) continue
146
+ const eq = line.indexOf('=')
147
+ if (eq !== -1) {
148
+ const key = line.slice(0, eq).trim()
149
+ const val = line.slice(eq + 1).trim()
150
+ if (key === 'project_id' && val) return val
151
+ continue
152
+ }
153
+ if (firstBare === null) firstBare = line
154
+ }
155
+ return firstBare
156
+ }
157
+
137
158
  function findSiesaProject(startDir) {
138
- // Walk up from startDir looking for a `.siesa-project` file. Returns its first non-empty
139
- // line trimmed, or null. Lets a parent dir override the git-remote-derived project_id —
159
+ // Walk up from startDir looking for a `.siesa-project` file. Returns the project_id slug
160
+ // it declares, or null. Lets a parent dir override the git-remote-derived project_id —
140
161
  // useful when several sub-repos (docs/backend/frontend) belong to the same logical project.
141
162
  let cur = path.resolve(startDir)
142
163
  for (let i = 0; i < 40; i++) {
143
164
  const candidate = path.join(cur, '.siesa-project')
144
165
  if (fs.existsSync(candidate)) {
145
166
  try {
146
- const first = fs.readFileSync(candidate, 'utf8').split(/\r?\n/)[0].trim()
147
- if (first) return first
167
+ const slug = parseSiesaProject(fs.readFileSync(candidate, 'utf8'))
168
+ if (slug) return slug
148
169
  } catch (_) {}
149
170
  }
150
171
  const parent = path.dirname(cur)
@@ -63,6 +63,27 @@ function tryExec(cmd, opts) {
63
63
  }
64
64
  }
65
65
 
66
+ // Parse a `.siesa-project` file body. Preferred format is `project_id=<slug>` (one key=value
67
+ // line, optional `#` comments). For backward compatibility a bare slug on its own line is
68
+ // also accepted. Returns the resolved slug, or null if nothing usable was found.
69
+ function parseSiesaProject(content) {
70
+ if (!content) return null
71
+ let firstBare = null
72
+ for (const raw of content.split(/\r?\n/)) {
73
+ const line = raw.trim()
74
+ if (!line || line.startsWith('#')) continue
75
+ const eq = line.indexOf('=')
76
+ if (eq !== -1) {
77
+ const key = line.slice(0, eq).trim()
78
+ const val = line.slice(eq + 1).trim()
79
+ if (key === 'project_id' && val) return val
80
+ continue
81
+ }
82
+ if (firstBare === null) firstBare = line
83
+ }
84
+ return firstBare
85
+ }
86
+
66
87
  // Walk up from `startDir` looking for an existing `.siesa-project`. Returns
67
88
  // { path, value } of the first match found, or null.
68
89
  function findExistingSiesaProject(startDir) {
@@ -71,8 +92,8 @@ function findExistingSiesaProject(startDir) {
71
92
  const candidate = path.join(cur, '.siesa-project')
72
93
  if (fs.existsSync(candidate)) {
73
94
  try {
74
- const first = fs.readFileSync(candidate, 'utf8').split(/\r?\n/)[0].trim()
75
- return { path: candidate, value: first }
95
+ const value = parseSiesaProject(fs.readFileSync(candidate, 'utf8'))
96
+ return { path: candidate, value }
76
97
  } catch (_) {
77
98
  return { path: candidate, value: null }
78
99
  }
@@ -186,15 +207,38 @@ function runDetect() {
186
207
  const derived = deriveLogicalSlug(repoName)
187
208
 
188
209
  if (!derived.matched) {
210
+ // Single-repo project: still write a `.siesa-project` so the project_id is explicit
211
+ // and self-documented. The slug is derived from the repo name, sanitized to kebab-case,
212
+ // and the file lands at the git root of the current project.
213
+ const singleRepoSlug = (repoName || '')
214
+ .toLowerCase()
215
+ .replace(/[^a-z0-9]+/g, '-')
216
+ .replace(/^-+|-+$/g, '')
217
+ if (!singleRepoSlug || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(singleRepoSlug)) {
218
+ emit({
219
+ status: 'no-remote',
220
+ message: 'Could not derive a valid kebab-case slug from the repo name. Pass --slug explicitly to write a .siesa-project.',
221
+ git_root: gitRoot,
222
+ remote_url: remoteUrl,
223
+ remote_repo: repoName,
224
+ })
225
+ process.exit(2)
226
+ }
189
227
  emit({
190
- status: 'single-repo',
191
- message: 'This repo name does not match the multi-repo convention (no docs|backend|frontend segment). sa-emit.js will report project_id based on the git remote, which is already unambiguous for single-repo projects. No .siesa-project is required.',
228
+ status: 'ready',
229
+ message: 'Single-repo project detected. The .siesa-project file will be written at the git root with a slug derived from the repo name.',
192
230
  git_root: gitRoot,
193
231
  remote_url: remoteUrl,
194
232
  remote_repo: repoName,
233
+ convention_match: false,
234
+ proposed_slug: singleRepoSlug,
235
+ convention_breakdown: null,
236
+ parent_candidates: [{ path: gitRoot, label: 'git root', levelsUp: 0 }],
237
+ recommended_dir: gitRoot,
195
238
  fallback_project_id: remoteParsed ? `${remoteParsed.org}/${remoteParsed.repo}` : null,
239
+ next_step_example: `node sa-init-project.js --write --slug "${singleRepoSlug}" --dir "${gitRoot}"`,
196
240
  })
197
- process.exit(2)
241
+ process.exit(0)
198
242
  }
199
243
 
200
244
  const candidates = listParentCandidates(gitRoot)
@@ -256,7 +300,7 @@ function runWrite(args) {
256
300
  const target = path.join(resolvedDir, '.siesa-project')
257
301
  if (fs.existsSync(target) && !force) {
258
302
  let current = null
259
- try { current = fs.readFileSync(target, 'utf8').split(/\r?\n/)[0].trim() } catch (_) {}
303
+ try { current = parseSiesaProject(fs.readFileSync(target, 'utf8')) } catch (_) {}
260
304
  emit({
261
305
  status: 'exists',
262
306
  message: '.siesa-project already exists at this location. Pass --force to overwrite.',
@@ -268,7 +312,7 @@ function runWrite(args) {
268
312
  }
269
313
 
270
314
  try {
271
- fs.writeFileSync(target, normalizedSlug + '\n', { encoding: 'utf8' })
315
+ fs.writeFileSync(target, `project_id=${normalizedSlug}\n`, { encoding: 'utf8' })
272
316
  } catch (err) {
273
317
  emit({ status: 'error', message: `Failed to write ${target}: ${err.message}` })
274
318
  process.exit(3)
@@ -0,0 +1,524 @@
1
+ #!/usr/bin/env node
2
+ // sa-init-devops.js — Bootstrap the DevOps workspace and skills in Siesa-Agents.
3
+ //
4
+ // In one invocation the script:
5
+ // 1. Clones (or pulls) architecture-sa-devops into `_siesa-agents/devops/`.
6
+ // 2. Detects conflicts: for each upstream `.claude/skills/sa-*/SKILL.md` and
7
+ // `.github/workflows/*.yml`, compares to the local copy in Siesa-Agents. If any local
8
+ // file differs from upstream and no policy flag was passed, aborts with `status:
9
+ // "conflicts"` and lists the conflicting items so the caller can ask the user how to
10
+ // resolve them.
11
+ // 3. If there are no conflicts, or the caller passed `--take-upstream` / `--keep-local`
12
+ // / `--keep-local=<csv>`, moves the upstream files into Siesa-Agents according to the
13
+ // policy and deletes the `.claude/` and `.github/` directories from the clone.
14
+ // 4. Mirrors the cleaned working tree to `siesa-agents/devops/`.
15
+ //
16
+ // Conflict policy flags (mutually exclusive):
17
+ // --take-upstream Overwrite all conflicting local files with the upstream copy.
18
+ // --keep-local Keep every local file as-is. Only create files that are
19
+ // entirely new in upstream.
20
+ // --keep-local=<csv> Keep these specific files (comma-separated names) local;
21
+ // overwrite the rest. Example:
22
+ // --keep-local=sa-aplicar,infra-pipeline-qa.yml
23
+ //
24
+ // Idempotency: between runs the clone's `.claude/` and `.github/` directories are gone from
25
+ // the working tree but still tracked in upstream HEAD. The script restores those paths
26
+ // (`git checkout HEAD -- .claude .github`) before the pull so it doesn't fail on
27
+ // "uncommitted deletions". The move-and-delete cycle then repeats.
28
+ //
29
+ // Modes:
30
+ // <no args> or --clone Default. Clone or update, detect conflicts, abort if any,
31
+ // otherwise move + delete + mirror.
32
+ // --check Read-only. Report the current state without mutating.
33
+ // --mirror-only Skip the git + move steps; rebuild the mirror from the
34
+ // current clone.
35
+ //
36
+ // Exit codes:
37
+ // 0 Success
38
+ // 1 Argument or usage error
39
+ // 2 No-op (--check on a clean state)
40
+ // 3 Git or filesystem mutation failed
41
+ // 4 Conflicts detected; the caller should ask the user and re-run with a policy flag.
42
+
43
+ 'use strict'
44
+
45
+ const fs = require('fs')
46
+ const path = require('path')
47
+ const { spawnSync } = require('child_process')
48
+
49
+ // TODO(siesa-admin): change to https://github.com/SiesaTeams/architecture-sa-devops.git
50
+ // once the repo is transferred to the SiesaTeams organization.
51
+ const REMOTE_URL = 'https://github.com/ssancheze912/architecture-sa-devops.git'
52
+ const REMOTE_NAME = 'origin'
53
+ const DEFAULT_BRANCH = 'main'
54
+
55
+ function parseArgs(argv) {
56
+ const out = { _keepLocalList: null }
57
+ for (const a of argv) {
58
+ if (!a.startsWith('--')) continue
59
+ const eq = a.indexOf('=')
60
+ if (eq === -1) {
61
+ out[a.slice(2)] = true
62
+ } else {
63
+ const key = a.slice(2, eq)
64
+ const val = a.slice(eq + 1)
65
+ out[key] = val
66
+ if (key === 'keep-local') {
67
+ out._keepLocalList = val.split(',').map(s => s.trim()).filter(Boolean)
68
+ }
69
+ }
70
+ }
71
+ return out
72
+ }
73
+
74
+ function emit(obj) {
75
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n')
76
+ }
77
+
78
+ function findProjectRoot(startDir) {
79
+ let cur = path.resolve(startDir)
80
+ for (let i = 0; i < 40; i++) {
81
+ if (fs.existsSync(path.join(cur, '_siesa-agents')) && fs.existsSync(path.join(cur, 'siesa-agents'))) {
82
+ return cur
83
+ }
84
+ const parent = path.dirname(cur)
85
+ if (parent === cur) return null
86
+ cur = parent
87
+ }
88
+ return null
89
+ }
90
+
91
+ function tryGit(args, cwd) {
92
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
93
+ return {
94
+ ok: r.status === 0,
95
+ code: r.status,
96
+ stdout: (r.stdout || '').trim(),
97
+ stderr: (r.stderr || '').trim(),
98
+ }
99
+ }
100
+
101
+ function isGitWorkingTree(dir) {
102
+ if (!fs.existsSync(path.join(dir, '.git'))) return false
103
+ const r = tryGit(['rev-parse', '--show-toplevel'], dir)
104
+ return r.ok && path.resolve(r.stdout) === path.resolve(dir)
105
+ }
106
+
107
+ function currentRemoteUrl(dir) {
108
+ const r = tryGit(['remote', 'get-url', REMOTE_NAME], dir)
109
+ return r.ok ? r.stdout : null
110
+ }
111
+
112
+ function rmDirSafe(dir) {
113
+ fs.rmSync(dir, { recursive: true, force: true })
114
+ }
115
+
116
+ function copyTreeWithoutGit(srcDir, destDir) {
117
+ fs.mkdirSync(destDir, { recursive: true })
118
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true })
119
+ for (const ent of entries) {
120
+ if (ent.name === '.git') continue
121
+ const srcPath = path.join(srcDir, ent.name)
122
+ const destPath = path.join(destDir, ent.name)
123
+ if (ent.isDirectory()) {
124
+ copyTreeWithoutGit(srcPath, destPath)
125
+ } else if (ent.isFile()) {
126
+ fs.copyFileSync(srcPath, destPath)
127
+ } else if (ent.isSymbolicLink()) {
128
+ const target = fs.readlinkSync(srcPath)
129
+ try { fs.symlinkSync(target, destPath) } catch (_) { /* Windows may need elevated perms */ }
130
+ }
131
+ }
132
+ }
133
+
134
+ // Compare two files for equality, normalizing CRLF→LF so Windows line-ending quirks don't
135
+ // create spurious conflicts. Returns true if both files have the same logical content.
136
+ function filesEqual(a, b) {
137
+ if (!fs.existsSync(a) || !fs.existsSync(b)) return false
138
+ try {
139
+ const ba = fs.readFileSync(a).toString('utf8').replace(/\r\n/g, '\n')
140
+ const bb = fs.readFileSync(b).toString('utf8').replace(/\r\n/g, '\n')
141
+ return ba === bb
142
+ } catch (_) {
143
+ return false
144
+ }
145
+ }
146
+
147
+ function refreshMirror(sourceDir, mirrorDir, status) {
148
+ const hadMirror = fs.existsSync(mirrorDir)
149
+ if (hadMirror) rmDirSafe(mirrorDir)
150
+ copyTreeWithoutGit(sourceDir, mirrorDir)
151
+ status.mirror_refreshed = true
152
+ status.mirror_dir = mirrorDir
153
+ status.mirror_existed_before = hadMirror
154
+ }
155
+
156
+ // Decide what to do with a single upstream item given the policy flags and the local state.
157
+ // Returns one of: 'create' (no local copy yet), 'identical' (already matches upstream),
158
+ // 'conflict' (differs and no policy says how to resolve), 'overwrite' (differs but policy
159
+ // resolves it as upstream-wins), 'keep-local' (differs but policy says keep local).
160
+ function decideAction(name, upstreamPath, localPaths, policy) {
161
+ const anyLocalExists = localPaths.some(p => fs.existsSync(p))
162
+ if (!anyLocalExists) return 'create'
163
+
164
+ // If every local copy matches upstream, nothing to do.
165
+ if (localPaths.every(p => filesEqual(p, upstreamPath))) return 'identical'
166
+
167
+ // At least one local copy differs from upstream — this is a conflict.
168
+ if (policy.keepLocalAll) return 'keep-local'
169
+ if (policy.keepLocalList && policy.keepLocalList.includes(name)) return 'keep-local'
170
+ if (policy.takeUpstream) return 'overwrite'
171
+ return 'conflict'
172
+ }
173
+
174
+ // MOVE `<sourceDir>/.claude/skills/sa-*/SKILL.md` into Siesa-Agents `.claude/skills/sa-*/`
175
+ // (and `claude/skills/sa-*/`) according to the policy. Records conflicts in
176
+ // status.skill_conflicts; the caller checks that before any mutation happens.
177
+ function planAndApplySkills(sourceDir, projectRoot, status, policy, dryRun) {
178
+ const srcSkillsDir = path.join(sourceDir, '.claude', 'skills')
179
+ status.skills_created = []
180
+ status.skills_overwritten = []
181
+ status.skills_kept_local = []
182
+ status.skills_unchanged = []
183
+ status.skill_conflicts = []
184
+
185
+ if (!fs.existsSync(srcSkillsDir)) {
186
+ status.skills_source_missing = true
187
+ return
188
+ }
189
+
190
+ const dstA = path.join(projectRoot, '.claude', 'skills')
191
+ const dstB = path.join(projectRoot, 'claude', 'skills')
192
+
193
+ for (const entry of fs.readdirSync(srcSkillsDir, { withFileTypes: true })) {
194
+ if (!entry.isDirectory()) continue
195
+ if (!entry.name.startsWith('sa-')) continue
196
+ if (entry.name === 'sa-init-devops') continue // never overwrite the bootstrap itself
197
+ const srcSkill = path.join(srcSkillsDir, entry.name, 'SKILL.md')
198
+ if (!fs.existsSync(srcSkill)) continue
199
+
200
+ const dstSkillA = path.join(dstA, entry.name, 'SKILL.md')
201
+ const dstSkillB = path.join(dstB, entry.name, 'SKILL.md')
202
+
203
+ const action = decideAction(entry.name, srcSkill, [dstSkillA, dstSkillB], policy)
204
+
205
+ if (action === 'conflict') {
206
+ status.skill_conflicts.push(entry.name)
207
+ continue
208
+ }
209
+ if (action === 'identical') {
210
+ status.skills_unchanged.push(entry.name)
211
+ continue
212
+ }
213
+ if (dryRun) continue
214
+
215
+ if (action === 'create' || action === 'overwrite') {
216
+ fs.mkdirSync(path.dirname(dstSkillA), { recursive: true })
217
+ fs.mkdirSync(path.dirname(dstSkillB), { recursive: true })
218
+ fs.copyFileSync(srcSkill, dstSkillA)
219
+ fs.copyFileSync(srcSkill, dstSkillB)
220
+ if (action === 'create') status.skills_created.push(entry.name)
221
+ else status.skills_overwritten.push(entry.name)
222
+ } else if (action === 'keep-local') {
223
+ status.skills_kept_local.push(entry.name)
224
+ }
225
+ }
226
+ }
227
+
228
+ function planAndApplyWorkflows(sourceDir, projectRoot, status, policy, dryRun) {
229
+ const srcDir = path.join(sourceDir, '.github', 'workflows')
230
+ status.workflows_created = []
231
+ status.workflows_overwritten = []
232
+ status.workflows_kept_local = []
233
+ status.workflows_unchanged = []
234
+ status.workflow_conflicts = []
235
+
236
+ if (!fs.existsSync(srcDir)) {
237
+ status.workflows_source_missing = true
238
+ return
239
+ }
240
+
241
+ const dstDir = path.join(projectRoot, '.github', 'workflows')
242
+
243
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
244
+ if (!entry.isFile()) continue
245
+ if (!(entry.name.endsWith('.yml') || entry.name.endsWith('.yaml'))) continue
246
+
247
+ const srcFile = path.join(srcDir, entry.name)
248
+ const dstFile = path.join(dstDir, entry.name)
249
+
250
+ const action = decideAction(entry.name, srcFile, [dstFile], policy)
251
+
252
+ if (action === 'conflict') {
253
+ status.workflow_conflicts.push(entry.name)
254
+ continue
255
+ }
256
+ if (action === 'identical') {
257
+ status.workflows_unchanged.push(entry.name)
258
+ continue
259
+ }
260
+ if (dryRun) continue
261
+
262
+ if (action === 'create' || action === 'overwrite') {
263
+ fs.mkdirSync(dstDir, { recursive: true })
264
+ fs.copyFileSync(srcFile, dstFile)
265
+ if (action === 'create') status.workflows_created.push(entry.name)
266
+ else status.workflows_overwritten.push(entry.name)
267
+ } else if (action === 'keep-local') {
268
+ status.workflows_kept_local.push(entry.name)
269
+ }
270
+ }
271
+ }
272
+
273
+ // After a successful apply (no conflicts left), wipe the .claude/ and .github/ directories
274
+ // from the clone working tree. These remain tracked in upstream HEAD; restoreClonePaths()
275
+ // brings them back before the next pull.
276
+ function cleanCloneDirs(sourceDir) {
277
+ for (const sub of ['.claude', '.github']) {
278
+ const p = path.join(sourceDir, sub)
279
+ if (fs.existsSync(p)) rmDirSafe(p)
280
+ }
281
+ }
282
+
283
+ // Before pulling, restore `.claude/` and `.github/` paths in the clone working tree.
284
+ // They are tracked in HEAD but get wiped at the end of every run. Without this restore,
285
+ // `git pull --ff-only` would refuse to proceed because the working tree has uncommitted
286
+ // deletions. `git checkout HEAD -- .claude .github` is a no-op if the paths already exist.
287
+ function restoreClonePaths(sourceDir) {
288
+ tryGit(['checkout', 'HEAD', '--', '.claude', '.github'], sourceDir)
289
+ }
290
+
291
+ function resolveTargets(projectRoot) {
292
+ return {
293
+ sourceDir: path.join(projectRoot, '_siesa-agents', 'devops'),
294
+ mirrorDir: path.join(projectRoot, 'siesa-agents', 'devops'),
295
+ }
296
+ }
297
+
298
+ function buildPolicy(args) {
299
+ // Both --keep-local with no value (`--keep-local` alone, no `=`) AND with a value (`--keep-local=...`)
300
+ // need to be distinguished. parseArgs sets out['keep-local'] = true when no `=`, and = '<value>' when there is.
301
+ const keepLocalArg = args['keep-local']
302
+ return {
303
+ takeUpstream: Boolean(args['take-upstream']),
304
+ keepLocalAll: keepLocalArg === true, // bare `--keep-local`
305
+ keepLocalList: Array.isArray(args._keepLocalList) ? args._keepLocalList : null,
306
+ }
307
+ }
308
+
309
+ function validatePolicy(policy, status) {
310
+ const setCount = [policy.takeUpstream, policy.keepLocalAll, policy.keepLocalList !== null].filter(Boolean).length
311
+ if (setCount > 1) {
312
+ status.status = 'error'
313
+ status.message = 'Conflicting flags: pass at most one of --take-upstream, --keep-local, --keep-local=<csv>.'
314
+ return false
315
+ }
316
+ return true
317
+ }
318
+
319
+ function runCheck(projectRoot) {
320
+ const { sourceDir, mirrorDir } = resolveTargets(projectRoot)
321
+ const state = {
322
+ status: 'unknown',
323
+ project_root: projectRoot,
324
+ source_dir: sourceDir,
325
+ mirror_dir: mirrorDir,
326
+ remote_url: REMOTE_URL,
327
+ }
328
+
329
+ if (!fs.existsSync(sourceDir)) {
330
+ state.status = 'missing'
331
+ state.message = `${sourceDir} does not exist. Run without --check to clone ${REMOTE_URL}.`
332
+ emit(state)
333
+ return 2
334
+ }
335
+
336
+ if (!isGitWorkingTree(sourceDir)) {
337
+ state.status = 'invalid-source'
338
+ state.message = `${sourceDir} exists but is not a git working tree. Move it aside or delete it manually before re-running this skill.`
339
+ emit(state)
340
+ return 3
341
+ }
342
+
343
+ const actualRemote = currentRemoteUrl(sourceDir)
344
+ state.actual_remote_url = actualRemote
345
+ if (actualRemote !== REMOTE_URL) {
346
+ state.status = 'wrong-remote'
347
+ state.message = `${sourceDir} is a git clone but its origin points at ${actualRemote} (expected ${REMOTE_URL}). Move it aside manually before re-running.`
348
+ emit(state)
349
+ return 3
350
+ }
351
+
352
+ state.mirror_exists = fs.existsSync(mirrorDir)
353
+ state.clone_has_claude_dir = fs.existsSync(path.join(sourceDir, '.claude'))
354
+ state.clone_has_github_dir = fs.existsSync(path.join(sourceDir, '.github'))
355
+
356
+ // Dry-run conflict detection so callers can preview before running default mode.
357
+ restoreClonePaths(sourceDir)
358
+ const policy = { takeUpstream: false, keepLocalAll: false, keepLocalList: null }
359
+ planAndApplySkills(sourceDir, projectRoot, state, policy, true)
360
+ planAndApplyWorkflows(sourceDir, projectRoot, state, policy, true)
361
+
362
+ state.status = 'already-cloned'
363
+ const conflictCount = state.skill_conflicts.length + state.workflow_conflicts.length
364
+ state.message = `${sourceDir} is a clean clone of ${REMOTE_URL}. ${conflictCount} conflict(s) would be raised by a default run.`
365
+ emit(state)
366
+ return 0
367
+ }
368
+
369
+ function runClone(projectRoot, mirrorOnly, args) {
370
+ const { sourceDir, mirrorDir } = resolveTargets(projectRoot)
371
+ const status = {
372
+ status: 'unknown',
373
+ project_root: projectRoot,
374
+ source_dir: sourceDir,
375
+ mirror_dir: mirrorDir,
376
+ remote_url: REMOTE_URL,
377
+ }
378
+
379
+ const policy = buildPolicy(args)
380
+ status.policy = {
381
+ take_upstream: policy.takeUpstream,
382
+ keep_local_all: policy.keepLocalAll,
383
+ keep_local_list: policy.keepLocalList,
384
+ }
385
+ if (!validatePolicy(policy, status)) {
386
+ emit(status)
387
+ return 1
388
+ }
389
+
390
+ const sourceExists = fs.existsSync(sourceDir)
391
+
392
+ if (mirrorOnly) {
393
+ if (!sourceExists) {
394
+ status.status = 'error'
395
+ status.message = `--mirror-only requested but ${sourceDir} does not exist yet.`
396
+ emit(status)
397
+ return 3
398
+ }
399
+ refreshMirror(sourceDir, mirrorDir, status)
400
+ status.status = 'mirror-refreshed'
401
+ status.message = `Refreshed ${mirrorDir} from ${sourceDir} (skipped git + move steps).`
402
+ emit(status)
403
+ return 0
404
+ }
405
+
406
+ // --- Path 1: source dir does not exist → clone fresh ---
407
+ if (!sourceExists) {
408
+ fs.mkdirSync(path.dirname(sourceDir), { recursive: true })
409
+ const r = tryGit(['clone', '--branch', DEFAULT_BRANCH, REMOTE_URL, sourceDir], path.dirname(sourceDir))
410
+ if (!r.ok) {
411
+ status.status = 'clone-failed'
412
+ status.message = `git clone failed: ${r.stderr || r.stdout || 'unknown error'}`
413
+ emit(status)
414
+ return 3
415
+ }
416
+ return finalizeMoveAndMirror(sourceDir, projectRoot, mirrorDir, status, policy, 'cloned')
417
+ }
418
+
419
+ // --- Path 2: source exists, validate it's the right clone ---
420
+ if (!isGitWorkingTree(sourceDir)) {
421
+ status.status = 'invalid-source'
422
+ status.message = `${sourceDir} exists but is not a git working tree. Move it aside or delete it manually before re-running.`
423
+ emit(status)
424
+ return 3
425
+ }
426
+
427
+ const actualRemote = currentRemoteUrl(sourceDir)
428
+ status.actual_remote_url = actualRemote
429
+ if (actualRemote !== REMOTE_URL) {
430
+ status.status = 'wrong-remote'
431
+ status.message = `${sourceDir} is a git clone but origin points at ${actualRemote}. Expected ${REMOTE_URL}. Move it aside manually before re-running.`
432
+ emit(status)
433
+ return 3
434
+ }
435
+
436
+ // --- Path 3: existing clean clone → restore .claude/.github + fetch + pull --ff-only ---
437
+ restoreClonePaths(sourceDir)
438
+
439
+ const fetchR = tryGit(['fetch', REMOTE_NAME, DEFAULT_BRANCH, '--prune'], sourceDir)
440
+ if (!fetchR.ok) {
441
+ status.status = 'fetch-failed'
442
+ status.message = `git fetch failed: ${fetchR.stderr || fetchR.stdout || 'unknown error'}`
443
+ emit(status)
444
+ return 3
445
+ }
446
+
447
+ const beforeR = tryGit(['rev-parse', 'HEAD'], sourceDir)
448
+ const before = beforeR.ok ? beforeR.stdout : null
449
+
450
+ const checkoutR = tryGit(['checkout', DEFAULT_BRANCH], sourceDir)
451
+ if (!checkoutR.ok) {
452
+ status.status = 'checkout-failed'
453
+ status.message = `git checkout ${DEFAULT_BRANCH} failed: ${checkoutR.stderr || checkoutR.stdout}`
454
+ emit(status)
455
+ return 3
456
+ }
457
+
458
+ const pullR = tryGit(['pull', '--ff-only', REMOTE_NAME, DEFAULT_BRANCH], sourceDir)
459
+ if (!pullR.ok) {
460
+ status.status = 'pull-failed'
461
+ status.message = `git pull --ff-only failed: ${pullR.stderr || pullR.stdout}. Local edits or diverged branch may be the cause.`
462
+ emit(status)
463
+ return 3
464
+ }
465
+
466
+ const afterR = tryGit(['rev-parse', 'HEAD'], sourceDir)
467
+ const after = afterR.ok ? afterR.stdout : null
468
+ const moved = before && after && before !== after
469
+
470
+ status.before_sha = before
471
+ status.after_sha = after
472
+
473
+ return finalizeMoveAndMirror(sourceDir, projectRoot, mirrorDir, status, policy, moved ? 'updated' : 'already-cloned')
474
+ }
475
+
476
+ function finalizeMoveAndMirror(sourceDir, projectRoot, mirrorDir, status, policy, terminalStatus) {
477
+ // Phase A: dry-run to compute the action plan and conflicts.
478
+ planAndApplySkills(sourceDir, projectRoot, status, policy, true)
479
+ planAndApplyWorkflows(sourceDir, projectRoot, status, policy, true)
480
+
481
+ const totalConflicts = status.skill_conflicts.length + status.workflow_conflicts.length
482
+ if (totalConflicts > 0) {
483
+ // Abort without mutating. The caller (Claude/SKILL.md) asks the user and re-runs with
484
+ // a policy flag.
485
+ status.status = 'conflicts'
486
+ status.message = `Detected ${status.skill_conflicts.length} skill conflict(s) and ${status.workflow_conflicts.length} workflow conflict(s). Re-run with one of: --take-upstream (overwrite all), --keep-local (keep all local versions), or --keep-local=<csv> (keep specific ones).`
487
+ emit(status)
488
+ return 4
489
+ }
490
+
491
+ // Phase B: real apply (no conflicts left to resolve).
492
+ planAndApplySkills(sourceDir, projectRoot, status, policy, false)
493
+ planAndApplyWorkflows(sourceDir, projectRoot, status, policy, false)
494
+ cleanCloneDirs(sourceDir)
495
+ refreshMirror(sourceDir, mirrorDir, status)
496
+
497
+ status.status = terminalStatus
498
+ const created = status.skills_created.length + status.workflows_created.length
499
+ const over = status.skills_overwritten.length + status.workflows_overwritten.length
500
+ const kept = status.skills_kept_local.length + status.workflows_kept_local.length
501
+ const unch = status.skills_unchanged.length + status.workflows_unchanged.length
502
+ status.message = `${terminalStatus === 'cloned' ? 'Cloned' : (terminalStatus === 'updated' ? 'Pulled' : 'No upstream change')}. Created ${created}, overwrote ${over}, kept local ${kept}, unchanged ${unch}. Mirror refreshed.`
503
+ emit(status)
504
+ return 0
505
+ }
506
+
507
+ const args = parseArgs(process.argv.slice(2))
508
+ const projectRoot = findProjectRoot(process.cwd())
509
+ if (!projectRoot) {
510
+ emit({
511
+ status: 'error',
512
+ message: 'Could not locate the project root. Expected to find a parent directory containing both `_siesa-agents/` and `siesa-agents/`.',
513
+ cwd: process.cwd(),
514
+ })
515
+ process.exit(1)
516
+ }
517
+
518
+ if (args['check']) {
519
+ process.exit(runCheck(projectRoot))
520
+ } else if (args['mirror-only']) {
521
+ process.exit(runClone(projectRoot, true, args))
522
+ } else {
523
+ process.exit(runClone(projectRoot, false, args))
524
+ }