| name | internal-links |
| description | Use when: kullanıcı "internal links", "iç linkleme", "orphan page",
"kayıp sayfa", "iç link kırığı", "broken internal link", "redirect
chain", "yönlendirme zinciri", "anchor distribution", "anchor text
dağılımı", "iç link analizi" der ya da /pseo-internal-links çağırır.
Also use when: aktif projenin SF export'u mevcut (sf-import koşmuş);
projects/{slug}/sf-exports/{date}/raw/ altında all_inlinks.csv +
internal_all.csv var; orphan / broken / redirect-chain / anchor
diversity bulguları master.xlsx#master_task'a auto_generated entry
olarak yazılacak; quick-wins / cannibalization sonrası ek planning
pass'i olarak çağırılır.
Do not use when: SF import yapılmamış (önce sf-import çalıştır,
DURUR #1); GSC quick-wins / cannibalization tetiklenecek (ayrı
discovery skill'leri); tek-URL on-page audit (on-page-audit); DFS
bütçesi aşılmışsa --use-dfs flag KULLANMA, default no-cost path
yeterlidir. Master.xlsx yokken çağırma; init-project önce çalışmalı
(DURUR #6).
|
| version | 1.0 |
| status | active |
| category | planning |
| inputs | {"project_slug":{"type":"string","required":true,"description":"Slug; resolves projects/{slug}/master.xlsx + sf-exports/."},"sf_export_date":{"type":"string","required":false,"description":"ISO date of SF export to read (projects/{slug}/sf-exports/{date}/raw/). Default: latest folder under sf-exports/."},"default_status":{"type":"string","required":false,"default":"TODO","description":"statusEnum seed for new master_task rows (default TODO)."},"max_entries":{"type":"integer","required":false,"default":500,"description":"Hard cap on auto_generated rows; DURUR #7 vehicle when exceeded."},"use_dfs":{"type":"boolean","required":false,"default":false,"description":"Optional: enrich findings via mcp__dataforseo__on_page_content_parsing. Triggers budget pre-flight (paid MCP)."},"use_sf_mcp_live":{"type":"boolean","required":false,"default":false,"description":"Opt-in (D-SF-11): when true, calls SF MCP via scripts/util/sf_mcp_client.SfMcpClient for live all_inlinks (bypasses sf-exports CSV freshness gap). Resolves the crawl id from sf_list_crawls (domain match → instanceDirName), client.load_crawl(...) (resilient), then exports all_inlinks via SF_EXPORT_DISPATCH (sf_generate_bulk_export category 'Links:All Inlinks', export_type CSV) to file_path (the >100KB inline cap is resolved by writing to disk, not a non-existent 'truncated' flag), reads the native CSV with utf-8-sig (strips SF's BOM), parses with csv.DictReader → transform(inlinks_rows=...). Requires SF GUI + MCP server running (preflight via client.health(); on FAIL / no matching crawl / SfMcpToolError / load timeout → AMBER fallback to file-based path, NEVER hard fail per R9)."}} |
| outputs | ["master.xlsx#master_task","outputs/reports/{date}-internal-links.md","events.jsonl","inbox/sf/{date}-internal_links-{slug}.json"] |
| consumes | ["init-project:projects/{slug}/master.xlsx","sf-import:projects/{slug}/sf-exports/{date}/raw/"] |
| produces | ["drift-check","monthly-report"] |
| triggers | {"manual":["/pseo-internal-links"],"natural_language":"\"internal links\", \"iç linkleme\", \"orphan page\", \"kayıp sayfa\",\n\"iç link kırığı\", \"broken internal link\", \"redirect chain\",\n\"yönlendirme zinciri\", \"anchor distribution\", \"anchor text dağılımı\",\n\"iç link analizi\"\n","hooks":[],"scheduled":[{"cron":"0 9 * * 3","mode":"report-only"}]} |
| mcp_tools | {"required":[],"optional":["mcp__dataforseo__on_page_content_parsing","mcp__sf__sf_list_crawls","mcp__sf__sf_load_crawl","mcp__sf__sf_generate_bulk_export"]} |
| budget | {"uses_paid_mcp":false,"estimated_credits":0} |
| autonomy | {"confidence":"HIGH","requires_approval":true,"safe_auto_execute":false} |
internal-links — planning skill (Phase 8 Wave 1)
10-step protocol. Steps map 1:1 to workflow_runner invocations + the
spec §6 SF ingest discipline + §16.5 raw-inbox transform discipline.
Raw CSV envelope discipline is mandatory: SF CSVs are described in a
single envelope-JSON dropped into inbox/sf/ before any per-row
transform runs, so a transform bug never costs us the upstream payload.
This skill follows the convention authority established by
skills/discovery/cannibalization/SKILL.md (Phase 7 Wave 1) and
skills/ingestion/sf-import/SKILL.md (Phase 5 Wave 2). The 10-step
protocol shape, raw envelope discipline, URL normalization (D-03),
DURUR + flag rule, and provenance event format are reused verbatim —
only the data source (SF CSV instead of GSC MCP) and the target sheet
(master_task auto_generated rows instead of a sheet-specific layout)
change. Deviate only with an ADR.
Unlike Phase 7 discovery skills that read GSC/DFS MCP responses, this
planning skill reads SF CSV files directly from disk. There is NO
DFS dependency in the default path; _normalize_dfs_response (D-003
helper) is NOT imported here because no DFS payload is consumed. The
optional --use-dfs enrichment path (anchor-text quality scoring) DOES
gate behind scripts/budget/check_budget.py per §16.8 + ADR-016, but
the default flow burns 0 credits.
Inputs (frontmatter contract)
| Name | Type | Default | Notes |
|---|
project_slug | string | — | Required. Resolves projects/{slug}/master.xlsx + sf-exports/. |
sf_export_date | string | latest | Reads projects/{slug}/sf-exports/{date}/raw/. |
default_status | string | "TODO" | statusEnum seed for new master_task rows. |
max_entries | integer | 500 | DURUR #7 cap; >500 entries → flag for batching. |
use_dfs | boolean | false | Opt-in DFS enrichment; triggers budget pre-flight. |
workspace_root is resolved via PSEO_WORKSPACE_ROOT env or explicit
test override (mirrors workflow_runner / events_writer).
Outputs (artifacts produced)
projects/{slug}/master.xlsx#master_task — N auto_generated rows
(one per orphan / broken / redirect-chain / anchor finding), 19
columns, auto_generated=true, primary_source=tech_fix.
projects/{slug}/outputs/reports/{date}-internal-links.md —
human-readable summary (orphan list + broken counts + redirect
chains + anchor diversity flags).
projects/{slug}/_state/events.jsonl — event_kind=provenance
entry (source.kind=sf_csv, target_excel_sheet=master_task).
projects/{slug}/inbox/sf/{date}-internal_links-{slug}.json —
drift-recovery envelope listing the SF CSVs read + finding counts.
Output Target Decision (Option A vs C)
The brief gave three options for where internal-links findings land:
| Option | Target | Schema Bump | Decision |
|---|
| A | master.xlsx#master_task auto_generated | NONE | CHOSEN |
| B | New master.xlsx#internal_links sheet | REQUIRED → ADR | OUT OF SCOPE (Wave 1, manager-only) |
| C | staging-only (_state/staging/...json) | NONE | rejected |
Choice: Option A — D-01 SSoT extension via master_task auto_generated entries.
Rationale:
- Single source of truth (D-01).
master_task is already the
workspace-wide task table consumed by dashboard_refresh,
done_protocol, whats-next. Putting internal-links findings
there means no downstream skill needs to learn a new sheet name —
reuse the existing master_task_sync writer scope (allowed_writers
list in master-excel.schema includes master_task_sync, which is
the planning-skill writer convention).
- Schema-neutral. Option B's new sheet would force a schema
bump → ADR → manager approval, breaking Wave 1 worker scope.
Option A uses only existing columns + existing enum values.
auto_generated=true flag is purpose-built (col O,
type: boolean) — it tells dashboard_refresh / done_protocol that
the row was synthesized by a planning skill and is eligible for
bulk dismissal / batch-approve flows. The schema enforces:
auto_generated (col O) MUST be a boolean.
primary_source (col C) MUST be in the closed enum
{content_decay, quickwin, tech_fix, schema, pillar, manual,
sxo, cannibalization, redirect_404}. We use tech_fix
(closest match for structural/internal-links findings; the
enum has NO internal_links value, see Open Question Q-IL-1).
note carries the analysis lineage prefix [internal-links/{kind}]
so glossary-audit / drift-check can find these rows by prefix.
metric_impact_json (col R) carries the per-finding diagnostic
payload (kind, status_code, inbound_count, etc.).
- Volume-tolerant via DURUR #7. If a single run produces
500 auto_generated entries, the transform raises
MaxEntriesExceededError and the skill flags the manager to
batch the run. Option C would silently dump everything and
defer the volume problem to Phase 9 — not better.
Option C (staging-only) was considered as fallback when entry count
exceeds the cap; rejected because dashboard_refresh / whats-next
already speak master_task — adding a parallel staging path doubles
the surface area for Phase 9 reporting integration without any data
benefit. If volume becomes structural (every run > cap), revisit via
ADR (Option B schema bump).
Source-field convention for auto_generated entries:
| Column | Value (per row) |
|---|
task_id | {slug}-{date}-il-{kind}-{idx:03d} (deterministic, slug-agnostic) |
task | Human label (e.g. "Orphan page (no internal inlinks): {url}") |
primary_source | "tech_fix" (enum value) |
related_sources | "internal-links" (free-text reference) |
category | "internal-links" (free-text) |
note | "[internal-links/{kind}] {detail}" (machine-parseable prefix) |
auto_generated | True (boolean — col O type=boolean) |
metric_impact_json | sorted-keys JSON of finding metrics |
10-Step Body Protocol
Each step name must match the steps[*].name passed to
workflow_runner.create_run. Names are stable identifiers across runs.
Step 1 — create_run
Open a workflow run shell. The state file lives at
projects/{slug}/_state/workflows/{run_id}.json (ADR-021).
from scripts.state import workflow_runner
handle = workflow_runner.create_run(
skill="internal-links",
project_slug=project_slug,
steps=[
{"name": "load_sf_csvs"},
{"name": "envelope_inbox"},
{"name": "transform"},
{"name": "request_approval"},
{"name": "write_excel"},
{"name": "render_report"},
],
)
Step 2 — load_sf_csvs (SF CSV first — drift-recoverable)
from scripts.planning import internal_links_transform as ilt
raw_dir = (
workspace_root / "projects" / project_slug
/ "sf-exports" / sf_export_date / "raw"
)
internal_rows, inlinks_rows, outlinks_rows = ilt.load_sf_csvs(raw_dir)
Raises SFDataMissingError (DURUR #1) when the dir is missing or no
SF CSVs are found, and CSVParseError (DURUR #2) on header mismatch
or encoding failure.
Step 3 — envelope_inbox (raw → drift-recoverable JSON)
For each loaded CSV: capture filename, row count, header set, and a
sha256 of the file bytes. Drop the envelope into
inbox/sf/{date}-internal_links-{slug}.json BEFORE any transform
runs. The envelope is the durable witness that a given SF batch was
observed even if the Excel write fails downstream.
envelope = {
"_meta": {
"captured_at": _utc_iso_z(),
"tool": "screaming_frog",
"skill": "internal-links",
"project_slug": project_slug,
"sf_export_path": str(raw_dir),
},
"files": [
{"canonical_name": "internal_all", "row_count": len(internal_rows or [])},
{"canonical_name": "all_inlinks", "row_count": len(inlinks_rows or [])},
{"canonical_name": "all_outlinks", "row_count": len(outlinks_rows or [])},
],
}
inbox_path = (
workspace_root / "projects" / project_slug
/ "inbox" / "sf"
/ f"{today.isoformat()}-internal_links-{project_slug}.json"
)
inbox_path.parent.mkdir(parents=True, exist_ok=True)
inbox_path.write_text(
json.dumps(envelope, ensure_ascii=False, indent=2),
encoding="utf-8",
)
Step 4 — transform (pure compute)
python3 scripts/planning/internal_links_transform.py \
--raw-dir projects/{slug}/sf-exports/{date}/raw/ \
--project-slug {slug} \
--run-date {date} \
--default-status TODO \
--max-entries 500 \
--output-dir _state/transform/{run_id}/
Produces a JSON array (master_task) shaped to the master-excel
schema (19 columns: task_id, task, primary_source, related_sources,
url, category, priority, impact, duration_est_min, status,
created_date, done_date, assignee, note, auto_generated, dependencies,
effort_actual_min, metric_impact_json, work_log_ref). URL normalization
(D-03) is applied here, not at load time, so the inbox envelope is
byte-faithful to the SF export.
The transform is idempotent: same inputs → byte-identical output.
If the result has meta.row_count == 0, the skill SKIPS write_excel
and emits a "no internal-links findings" notice (DURUR #8 — clean
exit, not error).
Step 5 — request_approval (skill EXIT awaiting_approval)
workflow_runner.request_approval(
handle.run_id, project_slug=project_slug,
approver="user",
subject=f"{len(rows)} internal-links bulgu (orphan/broken/redirect/anchor); "
"master.xlsx#master_task'a auto_generated entry yazalım mı?",
step_index=3,
)
Step 6 — Resume (approve → continue)
workflow_runner.approve(handle.run_id, project_slug=project_slug,
approver="user")
Step 7 — write_excel (atomic, schema-validated)
Single transaction.append call to the master_task sheet. The writer
is master_task_sync (allowed_writers list in master-excel.schema).
Goes through the single approved write path with backup, lock, schema
validation, and post-write provenance event emission.
from scripts.excel import transaction
transaction.append(
workbook_path=workspace_root/"projects"/project_slug/"master.xlsx",
sheet="master_task",
rows=master_task_rows,
project_slug=project_slug,
writer="master_task_sync",
)
Step 8 — render_report
render_template.py templates/reports/internal-links.template.md data.json → outputs/reports/{date}-internal-links.md. Variables:
$project_slug, $date, $orphan_count, $broken_count,
$redirect_chain_count, $anchor_diversity_count, $top_orphan_url,
$top_broken_url, $report_summary.
Step 9 — Provenance event
from scripts.state import events_writer
events_writer.append_provenance(
project_id=project_slug,
source={
"kind": "sf_csv",
"source_folder": str(raw_dir.relative_to(workspace_root)),
"row_count": (
len(internal_rows or []) + len(inlinks_rows or [])
),
},
operation="project_excel",
target_excel_sheet="master_task",
rows_written=len(master_task_rows),
)
Step 10 — complete
workflow_runner.complete(handle.run_id, project_slug=project_slug, outputs={
"row_count": str(len(master_task_rows)),
"orphan_count": str(meta["orphan_count"]),
"broken_count": str(meta["broken_count"]),
"redirect_chain_count": str(meta["redirect_chain_count"]),
"anchor_diversity_count": str(meta["anchor_diversity_count"]),
"report_path": str(report_path),
"envelope": str(inbox_path),
})
SF MCP Live Mode (Optional, use_sf_mcp_live=true — D-SF-11)
Default: use_sf_mcp_live=false — file-based ingestion (SF CSV from
projects/{slug}/sf-exports/{date}/raw/) is the canonical contract.
The flag is opt-in only and gated by a client.health() preflight; on
probe failure the run AMBER-warns and continues with the file-based
path (never hard-fail per R9 mitigation).
When use_sf_mcp_live=true, the SKILL body branches at Step 2
(load_sf_csvs) to optionally pull all_inlinks rows inline from SF
MCP instead of reading the on-disk CSV. The default no-DFS, no-MCP
path remains 0 credits and continues to satisfy the existing SF CSV
fixtures used in the 1184+ baseline.
This branch was rewritten (AC-13 replication) against the REAL SF MCP
API — the same class of fix as tech-audit (landed in 6ee6fb9,
live-proven on the demo-aluminum crawl) and the sf-crawl-orchestrator
Step-5 export dispatch (a714e43). The engine canonical all_inlinks
is NOT a Screaming Frog identifier: it maps via
sf_crawl_orchestrator.SF_EXPORT_DISPATCH to the real
sf_generate_bulk_export tool with category="Links:All Inlinks" +
export_type="CSV". There is NO crawl_id, report_name, or
save_report arg; the export runs on the currently-loaded crawl and
is written to file_path (relative to the SF allowed base directory).
The real SF REJECTS inline output >100KB with a tool error — and
all_inlinks is the single largest export (one row per edge), so
file_path (write-to-disk) is the only safe path (OQ-FILEPATH-EXPORTS),
not a non-existent truncated flag.
The sf_list_crawls result comes back through SfMcpClient.call_tool
as the MCP content envelope {"isError":false,"content":[{"text": "<JSON>"}]}, so the crawl list is JSON-encoded inside content[*].text
(exactly like sf_crawl_progress). We decode the first JSON block, then
match on url → instanceDirName. The real entry shape (Manager-probed)
is {"url":"https://demo-aluminum.example/","instanceDirName":"fc718e3f-..."}.
sf_generate_bulk_export writes a native CSV WITH a leading UTF-8 BOM
(live-verified) — so the written file is read with encoding="utf-8-sig",
otherwise csv.DictReader's first key arrives BOM-quoted
('"Address"') and every row silently drops.
import csv
import json
from pathlib import Path
from scripts.ingestion import sf_crawl_orchestrator
def _decode_sf_envelope(result: dict):
"""Decode the first JSON payload from an SfMcpClient content envelope.
SfMcpClient.call_tool returns {"isError":..,"content":[{"text":"<JSON>"}]}.
Returns the parsed object (list/dict) or None if no JSON block is present.
"""
for block in (result or {}).get("content", []) or []:
if isinstance(block, dict) and isinstance(block.get("text"), str):
try:
return json.loads(block["text"])
except ValueError:
continue
return None
def _norm_domain(u: str) -> str:
"""Normalise a URL for domain matching: drop scheme + leading www. +
trailing slash."""
bare = (u or "").strip().rstrip("/").split("://", 1)[-1].lower()
return bare[4:] if bare.startswith("www.") else bare
amber_warnings: list[str] = []
if use_sf_mcp_live:
from scripts.util.sf_mcp_client import (
SfMcpClient, SfMcpToolError, SfMcpError,
)
sf_cfg = project_config["sf"]["mcp"]
client = SfMcpClient(base_url=sf_cfg["url"])
allowed_dir = Path(sf_cfg["allowed_directory"])
if not client.health():
amber_warnings.append(
"SF MCP unavailable (health probe failed); "
"falling back to file-based path"
)
else:
try:
list_resp = client.call_tool("sf_list_crawls")
decoded = _decode_sf_envelope(list_resp)
crawls = decoded if isinstance(decoded, list) else (
decoded.get("crawls", []) if isinstance(decoded, dict) else []
)
target = _norm_domain(project_config["domain"])
match = next(
(c for c in crawls if _norm_domain(c.get("url", "")) == target),
None,
)
if match is None:
amber_warnings.append(
f"No SF crawl found for domain {project_config['domain']!r} "
"in sf_list_crawls; falling back to file-based path"
)
else:
crawl_id = match["instanceDirName"]
client.load_crawl(crawl_id)
tool, call_kwargs = sf_crawl_orchestrator.SF_EXPORT_DISPATCH[
"all_inlinks"
]
rel_path = "all_inlinks.csv"
client.call_tool(tool, file_path=rel_path, **call_kwargs)
csv_path = allowed_dir / rel_path
with csv_path.open("r", encoding="utf-8-sig", newline="") as fh:
inlinks_rows = list(csv.DictReader(fh))
except (SfMcpToolError, SfMcpError) as exc:
amber_warnings.append(
f"SF MCP error: {exc}; falling back to file-based path"
)
result = internal_links_transform.transform(
internal_all_rows=internal_all_rows,
inlinks_rows=inlinks_rows,
project_slug=project_slug,
run_date=run_date,
)
AMBER vs RED policy (R9 contract — never hard-fail this branch):
client.health() returns False → AMBER warning, continue file-based.
- No crawl matches the project domain in
sf_list_crawls → AMBER
warning, continue file-based.
SfMcpToolError / SfMcpError raised (export 4xx/5xx, JSON-RPC error,
load_crawl settle timeout) → AMBER warning, continue file-based.
- A partial SF outage degrades to the file-based
all_inlinks.csv
(orphan/broken/redirect-chain detectors handle missing edges
gracefully — affected_urls just understates).
- NEVER raise
SystemExit from this branch. RED reserved for the
existing DURUR set (SF data missing in BOTH paths, CSV parse error,
max_entries exceeded, etc.).
Tool naming reminder: call_tool(tool_name=...) takes the native
SF MCP tool name ("sf_generate_bulk_export", "sf_list_crawls",
"sf_load_crawl"), NOT the registry form ("sf__sf_generate_bulk_export")
or the Claude Code wrapper form ("mcp__sf__sf_generate_bulk_export").
JSON-RPC params.name follows the native form per MCP spec. SfMcpClient
already speaks the MCP Streamable-HTTP transport (session handshake +
SSE), so the body only ever passes native tool names + the SF call kwargs.
amber_warnings is surfaced via the existing provenance event
(an additional source.kind=sf_mcp event is emitted alongside the
sf_csv event at Step 9) and the rendered report template variable
$amber_warnings.
URL normalization (D-03 invariant)
Every URL passing through this skill is normalized via
scripts.planning.internal_links_transform.normalize_url. The function
is idempotent: normalize_url(normalize_url(u)) == normalize_url(u).
Rules: lowercase scheme+host, IDN→punycode, strip default ports, strip
trailing slash (root excluded), drop fragment, drop tracking params,
sort remaining query keys.
The transform self-checks idempotency on every emitted URL (post-row
build) — drift between transform and downstream consumers is escalated
as RowSchemaError rather than silently masked.
Detection rules (4 finding kinds)
| Kind | Condition | Severity | Duration |
|---|
| orphan-page | status=200 + indexable + 0 inlinks | HIGH | 30 min |
| broken-link | inlink edge destination status_code 4xx/5xx (per-dest agg) | HIGH/CRITICAL/MEDIUM | 15 min |
| redirect-chain | redirected internal link (single hop) — inlink edge destination status_code 3xx (per-dest agg) | LOW/MEDIUM | 20 min |
| anchor-diversity | dest URL ≥5 inbound non-empty anchors + only 1 distinct anchor | LOW | 25 min |
redirect-chain is a misnomer — it detects a single hop (I7). The
redirect-chain kind fires when an internal link points to a URL that
returns a 3xx, i.e. a redirected internal link (single hop) — it does
NOT trace a multi-hop chain. Real multi-hop redirect chains come from
Screaming Frog's redirect_chains report (the Number of Redirects /
Redirect Chain columns), which is consumed by GAP-T4 later. The machine
kind id stays redirect-chain (stable task_id contract); only the
human-facing description is corrected here.
task_id format: {slug}-{run_date}-il-{kind}-{idx:03d} —
deterministic + slug-agnostic; same input produces the same task_id
across re-runs.
Optional DFS path (paid MCP enrichment)
When the manager passes use_dfs=true, the skill enriches the
findings with mcp__dataforseo__on_page_content_parsing (anchor-text
quality scoring, link-position weighting). Pre-flight is MANDATORY:
from scripts.planning import internal_links_transform as ilt
estimate = ilt.estimate_credits(url_count=len(internal_rows), use_dfs=True)
ilt.preflight_budget(
estimated_credits=estimate,
project_config_path=workspace_root/"projects"/project_slug/"project.config.json",
events_path=workspace_root/"projects"/project_slug/"_state"/"events.jsonl",
)
BudgetExceededError (DURUR #6) is raised when projected daily usage
exceeds the cap; the skill must route the workflow to
awaiting_approval per ADR-016 / spec §10. The default no-DFS path
burns 0 credits and skips this gate entirely.
DURUR conditions (8)
Stop and flag the manager — do not patch, do not fall back.
- SF data missing —
projects/{slug}/sf-exports/{date}/raw/ does
not exist OR is empty (no internal_all.csv / all_inlinks.csv /
all_outlinks.csv). STOP, point user to run sf-import first.
SFDataMissingError.
all_inlinks.csv parse fail (header mismatch / encoding bad).
STOP, do not silently emit empty rows. CSVParseError.
master.xlsx#master_task column count or names don't match
schema (schemas/master-excel.schema.json#master_task,
19 required_columns). STOP, schema-first violation. Caught at
transform boundary as RowSchemaError.
transaction.append raises RowSchemaError (e.g.
auto_generated not boolean, status not in statusEnum,
priority not in severityEnum). STOP.
- Output target ambivalence — manager passed Option B request
(new sheet + schema bump). NOT in Wave 1 scope; route to manager
for ADR draft.
- Optional DFS path budget pre-flight FAIL (only when
use_dfs=true). BudgetExceededError — route to
awaiting_approval per ADR-016 / spec §10.
MaxEntriesExceededError — emitted entries exceed max_entries
(default 500). Manager flag for batching review (split run by
sub-folder or by finding kind).
- Transform reports
meta.row_count == 0 (no internal-links
findings). NOT an error — clean exit with a "no internal-links
findings" notice; skill skips write_excel + render_report and goes
straight to complete with row_count="0".
Cross-references
- Schemas:
schemas/master-excel.schema.json#master_task
(19 required_columns + #/definitions/statusEnum +
#/definitions/severityEnum + primary_source enum),
schemas/events.schema.json (source.kind=sf_csv,
target_excel_sheet=master_task),
schemas/sf-required-reports.schema.json (Tier 1 placement of
internal_all, all_inlinks, all_outlinks),
schemas/skill-frontmatter.schema.json (this frontmatter).
- Cross-modules (IMPORT-only):
scripts/state/workflow_runner.py,
scripts/excel/transaction.py, scripts/state/events_writer.py,
scripts/reporting/render_template.py,
scripts/budget/check_budget.py (only when use_dfs=true).
- Transform:
scripts/planning/internal_links_transform.py.
- Tests:
tests/skills/test_internal_links.py (≥6 cases).
- Pilot data path:
projects/{slug}/sf-exports/{export_date}/raw/.
Discipline checklist