@antoneeo/mkt-agentic-sdlc-skill 0.17.0 → 0.19.0

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
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.19.0] - 2026-10-17
4
+
5
+ ### Changed
6
+ - F-073 (shared validator and doctrine): one written form for a dated entry, `**YYYY-MM-DD — label.**`, is taught for spike outcomes and project notes that record an event, and shared readers let a chronology list those events the same way the validator reads them. Two advisories reach marketing, and neither fails a build, not even `--strict`: a HANDOFF `next:` longer than 240 characters on a `level: L3` file updated on or after 2026-10-18 (`next:` now holds only the next step), and, in `check`, an open L3 unit whose id another branch or tag uses for a different analysis. That id check identifies a unit by its ANALYSIS file; a marketing request record names none, so an engagement without an ANALYSIS is not checked yet. The doctrine now says to choose a new id after fetching, and that a session sharing a checkout with another session works in its own git worktree. Advisories print as `[advisory]` lines and never count toward the exit code.
7
+
8
+ ## [0.18.0] - 2026-10-17
9
+
10
+ ### Changed
11
+ - F1: closing the last open workstream now empties the generated registry `audit/handoff.md` instead of leaving its stale row behind: with zero `HANDOFF_*.md` sources `index` rewrites it empty (keeping `project_notes.md`), and `validate` reports a generated registry that still has rows but no sources as an error.
12
+ - F-069: `graph`, `check`, `stale` and the session-start count are faster on a mature knowledge base, with byte-identical output. Citations of superseded claim ids are found with one pass per living document instead of one scan per (document, id) pair, and one check run reads and resolves each cited extraction once instead of once per claim row. On a synthetic ledger of 2183 rows and 1000 living documents, `graph` drops from 14 s to 4 s. No new flag.
13
+
3
14
  ## [0.17.0] - 2026-10-13
4
15
 
5
16
  ### Changed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mkt-agentic-sdlc",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Evidence-First marketing planning protocol with engagement triage, SOSTAC workflow, evidence ledger and optional devPNT integration.",
5
5
  "author": "Antonio Pinto (https://github.com/Antoneeo)"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antoneeo/mkt-agentic-sdlc-skill",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Evidence-First marketing planning protocol for Claude Code, Gemini CLI, Google Antigravity and Codex: engagement triage, SOSTAC workflow, evidence ledger, mechanical validator and optional devPNT integration.",
5
5
  "keywords": [
6
6
  "claude-code",
package/scripts/init.js CHANGED
@@ -194,16 +194,16 @@ in the skill's \`routing.md\`: it decides which lens's method and validation rul
194
194
  that unit. L1 never reaches it. Never refer to a document whose meaning differs by lens
195
195
  ("threat model", "vision", \`principles.md\`, \`handoff.md\`) by its bare name.
196
196
 
197
- Select the owning lens by the unit's purpose, then apply only its risk scale.
198
- The project's always-on pointer stays intact: consult the selected skill rather than
199
- merging multiple mandatory ladders. All lenses already carry shared project memory
200
- (\`memory.md\`); another installation is not needed for cross-domain recall.
197
+ Select the owning lens by the unit's purpose, then apply only its risk scale.
198
+ The project's always-on pointer stays intact: consult the selected skill rather than
199
+ merging multiple mandatory ladders. All lenses already carry shared project memory
200
+ (\`memory.md\`); another installation is not needed for cross-domain recall.
201
201
  This file is NOT auto-loaded by any client: it is a note for you, deliberately additive.
202
- Delete this note once the routing pointer is integrated.
202
+ Delete this note once the routing pointer is integrated.
203
203
  `, 'multi-lens routing note');
204
204
  if (wrote && protocolPreexisting) {
205
205
  console.log(' ⚠️ A protocol pointer already existed and was NOT overwritten.');
206
- console.log(' Integrate the routing pointer from AGENTIC_MULTI_LENS.md; keep one risk scale per unit.');
206
+ console.log(' Integrate the routing pointer from AGENTIC_MULTI_LENS.md; keep one risk scale per unit.');
207
207
  }
208
208
  }
209
209
 
@@ -14,17 +14,17 @@ python "<skill_dir>/scripts/mkt_check.py" check
14
14
 
15
15
  ## 2. Check in CI (recommended for teams)
16
16
 
17
- Copy all three validator files into the repository: `scripts/mkt_check.py`, `scripts/sdlc_core.py`, and `scripts/knowledge.py`, keeping them side by side. Then add to the pipeline:
17
+ Copy all three validator files into the repository: `scripts/mkt_check.py`, `scripts/sdlc_core.py`, and `scripts/knowledge.py`, keeping them side by side. Then add to the pipeline:
18
18
 
19
19
  ```
20
20
  python tools/mkt_check.py validate --strict
21
21
  ```
22
22
 
23
- The validator ships as **three files**: the domain entry point, `sdlc_core.py` and `knowledge.py`. Keep all three together. The core alone still runs but omits shared-memory/KB checks and domain-specific overlays; it is not an equivalent CI gate. `check` includes the knowledge surface when present.
23
+ The validator ships as **three files**: the domain entry point, `sdlc_core.py` and `knowledge.py`. Keep all three together. The core alone still runs but omits shared-memory/KB checks and domain-specific overlays; it is not an equivalent CI gate. `check` includes the knowledge surface when present.
24
24
 
25
25
  Effect: an unregenerated index, invalid frontmatter, a missing security section or incoherent states **block the pipeline** instead of relying on the agent's memory. `--strict` also fails on warnings and on a missing `ai_docs/`, so a wrong working directory cannot produce a green pipeline. This works because documents travel in the same PR as the code (Phase 5 rule).
26
26
 
27
- Note: the copy in the repo is the authoritative one for CI; update it when you update the skill — all three files, together.
27
+ Note: the copy in the repo is the authoritative one for CI; update it when you update the skill — all three files, together.
28
28
 
29
29
  **Projects whose docs root is not `ai_docs/`.** Pass `--docs-dir <name>` (e.g. `--docs-dir mkt_docs`) on any subcommand, or set `AGENTIC_SDLC_DOCS_DIR`. Without either, the validator walks up from the working directory and takes the nearest root it recognizes. If it finds two side by side — the shape of a half-finished migration — it refuses and names both rather than validating half a project and printing a verdict. `ai_docs/` remains the default and the recommended root: the parameter exists so a legacy tree can be read and migrated, not so a second one can be kept.
30
30
 
@@ -155,7 +155,7 @@ Gemini CLI — wire the same command into its startup-hook mechanism if present;
155
155
  **Usage notes:**
156
156
  - The hook assumes the working directory is the project root (standard Claude Code hook behavior); it also accepts `--root <path>`.
157
157
  - **Hybrid/devPNT projects**: add `--hybrid` — the hook then appends a one-line pointer to run `devpnt_mcp_get_bootstrap` for the Master Plan / Knowledge Layer and does not replicate them; the filesystem orientation (router + handoff + README) still emits.
158
- - Like the CI gate (§2), if you copied the validator into the repo, the hook references that copy — keep all three files current when you update the skill.
158
+ - Like the CI gate (§2), if you copied the validator into the repo, the hook references that copy — keep all three files current when you update the skill.
159
159
 
160
160
  ### Per-turn reminder: `remind` (UserPromptSubmit -- wired by default)
161
161
 
@@ -215,7 +215,7 @@ It aggregates the test files (`test_plan.py` + `test_session_start.py` + `test_s
215
215
 
216
216
  **Optional CI** (same shape as §2, not mandatory): add a `run:` step invoking the `unittest discover` command above.
217
217
 
218
- **T10 note:** if you copied the validator (all three files) and the `test_*.py` battery into the repo for CI, that copy is authoritative — keep it current when you update the skill.
218
+ **T10 note:** if you copied the validator (all three files) and the `test_*.py` battery into the repo for CI, that copy is authoritative — keep it current when you update the skill.
219
219
 
220
220
  ## `benefit` is a report, never a CI gate
221
221
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: mkt-agentic-sdlc
3
- version: 0.17.0
3
+ version: 0.19.0
4
4
  description: Evidence-First marketing planning protocol with engagement triage, SOSTAC workflow, evidence ledger, adversarial CMO review, a complete Standalone mode and optional symbiosis with devPNT. Use for marketing plans, go-to-market strategy, campaign planning and market research.
5
5
  author: Antonio Pinto (https://github.com/Antoneeo)
6
6
  copyright: (c) 2026 Antonio Pinto
@@ -79,7 +79,7 @@ One event, one destination: when the trigger fires and the document does not exi
79
79
  |---|---|---|
80
80
  | `audit/requests/REQUEST_[engagement].md` | Every E3, the moment the level is declared — before Wave 1 and the Vision Gate: R1 quotes the client's opening words verbatim; every later statement of theirs that settles something (answer, approval, budget, ruling) is appended before acting on it. Append-only, never deleted; artifacts and owner-given `FACT` rows cite its entries (`templates.md`). | 1 → 9 |
81
81
  | `audit/handoff.md` (workstream registry) | **Never by hand — generated by `mkt_check.py index` from the `HANDOFF_*.md` sources**, and `validate` errors when the two disagree. Regenerate at every closure and at session end; the `Date:` header is derived, so no writer touches it. Inventory for lookup, never a work board. | 9 / session end |
82
- | `audit/HANDOFF_[engagement].md` | **One per OPEN engagement, with or without volatile state** — it is the authored home of that engagement's registry row (frontmatter `workstream`/`level`/`branch`/`status`/`since`/`next`/`details`/`updated`), so no file means no row. DELETED at closure — deleting it *is* removing the row. | 9 / session end |
82
+ | `audit/HANDOFF_[engagement].md` | **One per OPEN engagement, with or without volatile state** — it is the authored home of that engagement's registry row (frontmatter `workstream`/`level`/`branch`/`status`/`since`/`next`/`details`/`updated`), so no file means no row. Its `next:` is the next concrete step in one sentence; what happened, and the state of the work, go to the Diary as a dated entry. DELETED at closure — deleting it *is* removing the row. | 9 / session end |
83
83
  | `audit/project_notes.md` | A note true for the whole engagement portfolio rather than one engagement. Appended verbatim to the generated registry, so regenerating cannot destroy it. | 9 / session end |
84
84
  | `audit/handoff.md` — converting an existing project | **Lazily, at the first write — and then ALL AT ONCE.** Converting one row at a time is the state that loses the others, so `index` refuses to write while anything in the file is unaccounted for and names it. | 9 / session end |
85
85
  | `audit/HANDOFF_[engagement].md` | Session ends with that engagement unfinished AND there is volatile resume state. Ephemeral, deleted at closure. | 8 / 9 / session end |
@@ -167,7 +167,7 @@ Deliverable language: ask for (or infer) the target market's language in Discove
167
167
  - `mkt_check.py budget && mkt_check.py funnel && mkt_check.py trace` must pass. A tactic that serves no objective is deleted or the objective set is revisited — never silently kept.
168
168
 
169
169
  ### 8. Action
170
- - **Isolate the work (Branch/worktree hygiene).** Execution rewrites plan documents other people are reading: do it on a branch or a worktree, never directly on the shared plan.
170
+ - **Isolate the work (Branch/worktree hygiene).** Execution rewrites plan documents other people are reading: do it on a branch or a worktree, never directly on the shared plan. A session that may run while another session uses the same checkout works in its **own git worktree**, and never switches branch, stashes, resets or runs `index` in a checkout another session may be using. Parallel work also shares ids: choose a new unit's id **after fetching**, as the first id free on every branch and tag the checkout can see.
171
171
  - **Consult (before acting)**: **consult the guide router** for a guide covering the task and read it first (the consult trigger, `guides.md` §0). A targeted description match, not a blanket read.
172
172
  - **Opt-in subagent execution**: for an approved plan, the work MAY be executed via subagents per `dispatch.md`; default stays same-session. `dispatch.md` also carries the family-wide **delegation boundary** (what may be delegated at all, and what never may) — that section is not gated by the opt-in trigger and applies at any level, dispatch or no dispatch.
173
173
  - Draft `ACTION_90D.md`: sequenced 90-day plan (what starts when, owner, dependency), first 2 weeks day-level, rest weekly.
@@ -125,6 +125,21 @@ def kb_unquote(value):
125
125
  return v
126
126
 
127
127
 
128
+ def frontmatter_date(meta):
129
+ """A note's or a sidecar's `date:` as `YYYY-MM-DD`, or None (F-073).
130
+ Quotes stripped, a datetime suffix tolerated, an impossible date refused --
131
+ the one parser behind orient's recency probe and any chronology reader."""
132
+ m = re.match(r"(\d{4}-\d{2}-\d{2})", kb_unquote((meta or {}).get("date")))
133
+ if not m:
134
+ return None
135
+ try:
136
+ import datetime as _dt
137
+ _dt.date.fromisoformat(m.group(1))
138
+ except ValueError:
139
+ return None
140
+ return m.group(1)
141
+
142
+
128
143
  def kb_parse_scope(text):
129
144
  """'-' | 'from X' | 'until X' | 'from X until Y' | 'if <cond>' ->
130
145
  ('always',) | ('window', from, until) | ('if', cond). Raises on junk."""
@@ -336,6 +351,17 @@ def kb_check_claims(root):
336
351
  if rel not in fm_cache:
337
352
  fm_cache[rel] = _note_frontmatter(root, rel)
338
353
  return fm_cache[rel]
354
+ # F-069: the same per-run memo for the source paths themselves -- each
355
+ # distinct path is resolved and stat'ed once, and each cited extraction
356
+ # read once (loc_cache, owned here, dropped when this call returns).
357
+ src_cache = {}
358
+ loc_cache = {}
359
+
360
+ def _resolved(path_s):
361
+ if path_s not in src_cache:
362
+ t = sdlc_core.confine_under(root, path_s)
363
+ src_cache[path_s] = (t, t is not None and t.is_file())
364
+ return src_cache[path_s]
339
365
  per_file_rows = []
340
366
  for p in sorted(topics.glob("*.md")):
341
367
  rel = "topics/" + p.name
@@ -357,16 +383,16 @@ def kb_check_claims(root):
357
383
  errors.append("%s: source %r has no locator" % (where, src))
358
384
  continue
359
385
  path_s, loc = src.rsplit("#", 1)
360
- target = sdlc_core.confine_under(root, path_s)
386
+ target, is_file = _resolved(path_s)
361
387
  if target is None:
362
388
  errors.append("%s: source %r escapes the docs root" % (where, src))
363
389
  continue
364
- if not target.is_file():
390
+ if not is_file:
365
391
  errors.append("%s: source %r does not resolve — a claim whose "
366
392
  "origin cannot be reopened is model knowledge"
367
393
  % (where, src))
368
394
  continue
369
- kb_check_locator(target, loc, where, errors)
395
+ kb_check_locator(target, loc, where, errors, loc_cache)
370
396
  # --- provenance ---
371
397
  prov = row["prov"]
372
398
  # Resolved ONCE, for every provenance: the non-GIVEN classes read it
@@ -556,8 +582,14 @@ def kb_check_claims(root):
556
582
  return errors, warnings, notes
557
583
 
558
584
 
559
- def kb_check_locator(target, loc, where, errors):
560
- """A locator must address an existing span of the stored bytes."""
585
+ def kb_check_locator(target, loc, where, errors, cache=None):
586
+ """A locator must address an existing span of the stored bytes.
587
+
588
+ `cache` is an optional dict the CALLER owns for one run (F-069): keyed by
589
+ the path actually read, it holds only what the checks compare against --
590
+ page lengths, a line count, or False for a missing extraction -- never the
591
+ text. Findings are still appended per call, so per row. Without it, every
592
+ call reads the file, as before."""
561
593
  m = LOC_PAGE_RE.match(loc)
562
594
  if m:
563
595
  page, a, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
@@ -566,17 +598,24 @@ def kb_check_locator(target, loc, where, errors):
566
598
  return
567
599
  # offsets address the stored extraction (<stem>.txt beside the original)
568
600
  ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
569
- if not ext.is_file():
601
+ key = ("p", ext)
602
+ if cache is None or key not in cache:
603
+ lengths = ([len(pg) for pg in sdlc_core.read_text(ext).split("\f")]
604
+ if ext.is_file() else False)
605
+ if cache is not None:
606
+ cache[key] = lengths
607
+ else:
608
+ lengths = cache[key]
609
+ if lengths is False:
570
610
  errors.append("%s: no stored extraction %s for locator %r — offsets "
571
611
  "must address kept bytes" % (where, ext.name, loc))
572
612
  return
573
- pages = sdlc_core.read_text(ext).split("\f")
574
- if page < 1 or page > len(pages):
613
+ if page < 1 or page > len(lengths):
575
614
  errors.append("%s: locator %r addresses page %d of %d" %
576
- (where, loc, page, len(pages)))
577
- elif b > len(pages[page - 1]):
615
+ (where, loc, page, len(lengths)))
616
+ elif b > lengths[page - 1]:
578
617
  errors.append("%s: locator %r spans past the end of page %d "
579
- "(%d chars)" % (where, loc, page, len(pages[page - 1])))
618
+ "(%d chars)" % (where, loc, page, lengths[page - 1]))
580
619
  return
581
620
  m = LOC_LINE_RE.match(loc)
582
621
  if m:
@@ -584,7 +623,13 @@ def kb_check_locator(target, loc, where, errors):
584
623
  if a > b or a < 1:
585
624
  errors.append("%s: locator %r has an invalid line range" % (where, loc))
586
625
  return
587
- n = sdlc_core.read_text(target).count("\n") + 1
626
+ key = ("L", target)
627
+ if cache is None or key not in cache:
628
+ n = sdlc_core.read_text(target).count("\n") + 1
629
+ if cache is not None:
630
+ cache[key] = n
631
+ else:
632
+ n = cache[key]
588
633
  if b > n:
589
634
  errors.append("%s: locator %r spans past line %d" % (where, loc, n))
590
635
  return
@@ -1085,12 +1130,18 @@ KB_GENERATED_RE = re.compile(r"GENERATED by \S+ index - do not edit by hand")
1085
1130
  KB_HEX12_RE = re.compile(r"^[0-9a-f]{12}$")
1086
1131
 
1087
1132
 
1088
- def _kb_hex_bounded(text, cid):
1089
- """True when cid appears NOT embedded in a longer hex run: a git hash
1090
- sharing a substring must never count as a citation (either side, either
1091
- case). cid is pre-validated 12-hex, so it is regex-safe by construction."""
1092
- return re.search(r"(?<![0-9a-fA-F])%s(?![0-9a-fA-F])" % cid,
1093
- text) is not None
1133
+ # A citation of a claim id is a run of exactly its 12 lowercase hex characters
1134
+ # NOT embedded in a longer hex run: a git hash sharing a substring must never
1135
+ # count as a citation (either side, either case). Two such bounded runs cannot
1136
+ # overlap, so findall returns every one of them.
1137
+ KB_HEX_TOKEN_RE = re.compile(r"(?<![0-9a-fA-F])[0-9a-f]{12}(?![0-9a-fA-F])")
1138
+
1139
+
1140
+ def _kb_hex_tokens(text):
1141
+ """The ids a text cites, extracted in one pass (F-069). Callers test
1142
+ membership instead of scanning the text once per id: that per-(document,
1143
+ id) scan made the time cycle grow with superseded ids x living documents."""
1144
+ return frozenset(KB_HEX_TOKEN_RE.findall(text))
1094
1145
 
1095
1146
 
1096
1147
  def _kb_df_entries(value):
@@ -1172,19 +1223,23 @@ def kb_time_cycle(docs):
1172
1223
  if (meta.get("status") or "").strip() == "SUPERSEDED":
1173
1224
  continue
1174
1225
  living.append((rel, text))
1175
- for rel, text in living:
1176
- for old in sorted(superseded):
1177
- if _kb_hex_bounded(text, old):
1178
- succs = superseded[old]
1179
- missing = tuple(x for x in succs
1180
- if not _kb_hex_bounded(text, x))
1181
- if missing:
1182
- cited.append((rel, old, missing, succs))
1226
+ # One token pass per living document, and none at all on a KB with no
1227
+ # supersession and no validity window (the orient line runs this at every
1228
+ # session start).
1229
+ tokens = ({rel: _kb_hex_tokens(text) for rel, text in living}
1230
+ if superseded or windows else {})
1231
+ for rel, _text in living:
1232
+ cites = tokens[rel] if tokens else frozenset()
1233
+ for old in cites.intersection(superseded):
1234
+ succs = superseded[old]
1235
+ missing = tuple(x for x in succs if x not in cites)
1236
+ if missing:
1237
+ cited.append((rel, old, missing, succs))
1183
1238
  today = _dt.date.today().isoformat()
1184
1239
  for topic_rel, rid, until in sorted(windows):
1185
1240
  if until <= today:
1186
- by = tuple(rel for rel, text in living
1187
- if rel != topic_rel and _kb_hex_bounded(text, rid))
1241
+ by = tuple(rel for rel, _text in living
1242
+ if rel != topic_rel and rid in tokens[rel])
1188
1243
  expired.append((topic_rel, rid, until, by))
1189
1244
  rev = {}
1190
1245
  node_meta = {}
@@ -1905,14 +1960,13 @@ def kb_cmd_orient(argv):
1905
1960
  # it -- the file was already fully read, so the cap bought
1906
1961
  # only failure. load_frontmatter is the authoritative
1907
1962
  # reader (fence-bounded, so a column-0 date: in the body
1908
- # still cannot misdate the note); kb_unquote covers quoted
1909
- # dates; the prefix match tolerates a datetime suffix.
1963
+ # still cannot misdate the note); frontmatter_date covers
1964
+ # quoted dates and tolerates a datetime suffix.
1910
1965
  meta = sdlc_core.load_frontmatter(
1911
1966
  sdlc_core.read_text(p).splitlines()) or {}
1912
- m = re.match(r"(\d{4}-\d{2}-\d{2})",
1913
- kb_unquote(meta.get("date")))
1914
- if m:
1915
- stamp = _dt.date.fromisoformat(m.group(1))
1967
+ day = frontmatter_date(meta)
1968
+ if day:
1969
+ stamp = _dt.date.fromisoformat(day)
1916
1970
  except Exception:
1917
1971
  pass
1918
1972
  if stamp is None:
@@ -735,6 +735,15 @@ def main(argv=None):
735
735
  code = max(code, sdlc_core.cmd_validate(root, strict=args.strict))
736
736
  code = max(code, knowledge.kb_validate_surface(root / sdlc_core.docs_dir(),
737
737
  full=args.command == "check"))
738
+ # F-073 advisories: Report has no advisory channel and --strict fails on
739
+ # any warning, so they print here, outside Report, never in `code`. On an
740
+ # ai_docs root cmd_validate above already printed A1/A2 -- once is the
741
+ # contract. A3 has no other path: this overlay never calls cmd_check.
742
+ notes = [] if sdlc_core.docs_dir() == "ai_docs" else sdlc_core.chronology_advisories(root)
743
+ if args.command == "check":
744
+ notes += sdlc_core.id_advisories(root)
745
+ for a in notes:
746
+ print(f"[advisory] {a}")
738
747
  if code == 0:
739
748
  print(f"[OK] {args.command} clean "
740
749
  f"({len(rep.warnings)} warning(s))" if rep.warnings else f"[OK] {args.command} clean")