@antoneeo/mkt-agentic-sdlc-skill 0.5.1 → 0.7.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,63 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0] - 2026-08-28
4
+
5
+ Spine + overlay delta parked for the next release of this lens (shipped first
6
+ in kb; the spine stays byte-identical x3 in the repo while packages publish
7
+ independently). In this lens the note is re-attached by the overlay
8
+ (`mkt_check.py`, after its own check verdict), because the overlay replaces
9
+ the spine's check pipeline entirely.
10
+
11
+ ### Added
12
+ - **F-041: `check` notes an unwired or dead session-orientation hook** (one
13
+ informational `[note]` line -- never the exit code, never a `validate` warning,
14
+ so CI is unaffected). `init` wires the hook only at init time; projects
15
+ initialized before F-036 or bootstrapped without `init` were silently unwired.
16
+
17
+ ### Added (F-042, parked with the above)
18
+ - Install/update wires the session-orientation hook machine-wide (Claude Code
19
+ with Python; user-level settings; removal is a standing per-target opt-out;
20
+ uninstall surgically removes what it wired). The check note is global-aware
21
+ and reworded in consequence-first user language.
22
+
23
+ ### Added (F-043 port, with the above)
24
+ - **The revision sentence.** Updating an existing strategy or campaign doc (the E2 row) is a REWRITE, not an
25
+ append: full re-read, body rewritten to current state -- never an appended
26
+ delta section. Ported from the kb lens's `## Revision` doctrine (kb 1.10.0),
27
+ born from a field post-mortem where delta-append revisions left stale
28
+ sentences standing as printed truth.
29
+
30
+ ## [0.6.0] - 2026-08-26
31
+
32
+ F-038 — the gated rung: truthful log vocabulary and a mandated ask, no grant memory.
33
+
34
+ ### Fixed
35
+ - **A review rung that exists behind a permission policy is no longer logged as
36
+ "unavailable".** The ladder knew two states (the client supports the rung, or not)
37
+ and hard-coded rung 3's reason as "no subagent facility on this client" — so a gated
38
+ facility produced false log rows, and the ladder's own prohibition ("rung 3 is
39
+ illegitimate wherever rung 1 or 2 exists") was unsatisfiable on a gated client: the
40
+ agent could not obey it without asking, and no rule said to ask.
41
+
42
+ ### Added
43
+ - **The gated-rung stop.** At a due review gate (design; closure), when the best rung is
44
+ permission-gated (a standing policy — an interactive per-call prompt is NOT this) and
45
+ no ungated rung works (a working one-shot CLI pre-empts the stop; a gated rung 2 joins
46
+ the question instead), the gate stops and asks ONE question in the five-bullet blocking
47
+ form: the gated rung(s) vs the fallback, each higher rung's status, the quoted policy,
48
+ the cost (~130-175k subagent tokens per deep review, measured 2026-08) AND what
49
+ independence last bought, and what stays blocked. **No grant memory**: the answer holds
50
+ while the conversation does; an agent that cannot recall a grant asks again. Unattended
51
+ runs never emit the question — rung 2 is still owed a try, then rung 3 with its reason.
52
+ (Successor to a design CANCELLED at the review cap after grant memory failed three
53
+ ways; rulings r18/r19.)
54
+ - **The reason words.** A below-rung-1 row says why the rung(s) above did not run:
55
+ `absent` (the client has no such facility — a claim about the client, never a policy),
56
+ `gated, declined`, `gated, unattended`, `gated, pre-empted` (nothing asked — an
57
+ ungated lower rung ran the review). A rung-1 row owes nothing. For rung 3 this
58
+ re-words an existing duty; for rung 2 it is a new one-word duty, disclosed and
59
+ accepted with the rest.
60
+
3
61
  ## [0.5.1] - 2026-08-25
4
62
 
5
63
  ### Fixed
package/README.md CHANGED
@@ -69,6 +69,10 @@ The workstream registry (`audit/handoff.md`) is **generated** from one file per
69
69
  - **Standalone** — everything on the filesystem under `mkt_docs/`.
70
70
  - **Hybrid with devPNT** — plans and strategy artifacts governed as versioned devPNT documents (MKT-VISION, ICP/Personas, Threat Map, Strategy, Tactical Plan, Measurement Plan), with proposal/approval workflow and independent review gates.
71
71
 
72
+ Installing or updating the npm package wires the session-orientation hook
73
+ machine-wide (user-level Claude Code settings; removal is a standing opt-out
74
+ that no update overrides -- ENFORCEMENT.md par.4).
75
+
72
76
  ## The family: three lenses, one spine
73
77
 
74
78
  | Package | Faithful to | Unit of work |
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mkt-agentic-sdlc",
3
- "version": "0.5.1",
3
+ "version": "0.7.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.5.1",
3
+ "version": "0.7.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
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { execSync } = require('child_process');
6
- const { SKILL_SOURCE, INSTALLED_SKILL_NAME, SELF_LENS, SIBLING_LENSES, CLIENTS, clientDetected, skillTarget, wireOrientHook, loadTemplates, templateFor } = require('./lib');
6
+ const { SKILL_SOURCE, INSTALLED_SKILL_NAME, SELF_LENS, SIBLING_LENSES, CLIENTS, clientDetected, skillTarget, wireOrientHook, detectPython, loadTemplates, templateFor } = require('./lib');
7
7
 
8
8
  const cwd = process.cwd();
9
9
 
@@ -150,16 +150,8 @@ let protocolPreexisting = false;
150
150
  // Probed ONCE and reused by section 6c: re-filtering CLIENTS there would
151
151
  // run every client's detection a second time.
152
152
  const detectedClients = CLIENTS.filter(clientDetected);
153
- const PYTHON_CANDIDATES = ['python', 'python3', 'py'];
154
- const detectedPython = (() => {
155
- for (const py of PYTHON_CANDIDATES) {
156
- try {
157
- execSync(`${py} --version`, { stdio: 'ignore' });
158
- return py;
159
- } catch (e) { /* try the next interpreter */ }
160
- }
161
- return null;
162
- })();
153
+ // One probe, shared with postinstall's global wiring (F-042): lib owns it.
154
+ const detectedPython = detectPython();
163
155
 
164
156
  for (const client of detectedClients) {
165
157
  {
@@ -273,8 +265,21 @@ if (!claudeClient) {
273
265
  hybrid: hybridProject, docsLabel: 'mkt_docs',
274
266
  });
275
267
  switch (r.code) {
268
+ case 'global':
269
+ // F-042: a resolving machine-global hook already orients every project.
270
+ console.log('⏭️ Covered by the machine-global orientation hook — no project-level');
271
+ console.log(` hook needed (${r.target}).`);
272
+ break;
276
273
  case 'wired':
277
274
  console.log(`🪝 Wired the SessionStart orientation hook in .claude/${r.file}.`);
275
+ if (r.globalNote === 'dead') {
276
+ console.log('⚠️ Note: a machine-global orientation hook exists but its validator');
277
+ console.log(' does not resolve — it emits nothing. Fix or remove it in your user');
278
+ console.log(' settings (ENFORCEMENT.md §4).');
279
+ } else if (r.globalNote === 'unverifiable') {
280
+ console.log('ℹ️ Note: your user settings carry an orientation hook this installer');
281
+ console.log(' cannot verify; the project-level hook above covers this project.');
282
+ }
278
283
  if (r.local) {
279
284
  console.log(' It names a path on THIS machine, so it went to the git-ignored');
280
285
  console.log(' file: each teammate runs init once to get their own.');
@@ -318,6 +323,12 @@ if (!claudeClient) {
318
323
  console.log(` hook command safely: ${r.validator}`);
319
324
  console.log(' Refusing to build one. Wire it by hand (ENFORCEMENT.md §4).');
320
325
  break;
326
+ default:
327
+ // A lens port missing a case must be loud, never a silent skip — the
328
+ // exact defect class the manual-snippet note below exists to prevent.
329
+ console.log(`ℹ️ Orientation hook: unhandled result '${r.code}'. Wire it by hand`);
330
+ console.log(' per ENFORCEMENT.md §4.');
331
+ break;
321
332
  }
322
333
  }
323
334
  for (const c of otherClients) {
package/scripts/lib.js CHANGED
@@ -389,6 +389,31 @@ function wireOrientHook(options) {
389
389
  : { code: 'broken', file: name, target: p, existing, command };
390
390
  }
391
391
 
392
+ // F-042: a resolving MACHINE-GLOBAL hook (user-level settings) already
393
+ // orients every project. Checked AFTER the project scan on purpose: a
394
+ // project-level broken/malformed/unverifiable must be reported first — a
395
+ // pre-check would mask a dead project hook behind "covered globally", the
396
+ // wired-and-DEAD state F-041 calls the worst of the three. A dead or
397
+ // unverifiable global hook falls through to the project write (the wrapper
398
+ // may be emitting nothing) and the caller gets `globalNote` to print the
399
+ // correction. A user-level token that is not absolute has no cwd to resolve
400
+ // against, so it is treated as unverifiable.
401
+ let globalNote;
402
+ {
403
+ const userTarget = path.join(client.home, 'settings.json');
404
+ const ustate = orientSettingsState(userTarget);
405
+ if (ustate.ok) {
406
+ const uexisting = findOrientHook(ustate.settings);
407
+ if (uexisting) {
408
+ const ufound = orientHookValidator(uexisting);
409
+ if (ufound !== null && path.isAbsolute(ufound) && fs.existsSync(ufound)) {
410
+ return { code: 'global', target: userTarget, command: uexisting };
411
+ }
412
+ globalNote = (ufound === null || !path.isAbsolute(ufound)) ? 'unverifiable' : 'dead';
413
+ }
414
+ }
415
+ }
416
+
392
417
  // A machine-specific command must not reach the shared, committed file.
393
418
  const file = vendored ? 'settings.json' : 'settings.local.json';
394
419
  const target = path.join(cwd, '.claude', file);
@@ -415,7 +440,209 @@ function wireOrientHook(options) {
415
440
  } catch (e) {
416
441
  return { code: 'write-failed', file, target, command, error: e.message };
417
442
  }
418
- return { code: 'wired', file, target, command, local: !vendored };
443
+ return { code: 'wired', file, target, command, local: !vendored, globalNote };
444
+ }
445
+
446
+ // --- F-042: the machine-global hook (install-time wiring) -------------------
447
+ // The F-041 field lesson: the check note asked a question a normal user cannot
448
+ // evaluate. The owner's ruling: the hook is wired by the act the user already
449
+ // performs — installing/updating the package — at the USER level, which the
450
+ // installer knows exactly. `orient` is fail-open (no docs root ⇒ silent), so a
451
+ // global hook is safe by construction on unmanaged projects. Removal is a
452
+ // STANDING opt-out: a per-target marker remembers every settings file ever
453
+ // wired, and no install or update ever re-adds a removed hook. Deleting the
454
+ // marker file is the documented re-enable gesture (ENFORCEMENT §4).
455
+
456
+ // Python detection, shared by init (project wiring + index) and postinstall
457
+ // (global wiring). One list, one probe.
458
+ const PYTHON_CANDIDATES = ['python', 'python3', 'py'];
459
+ function detectPython() {
460
+ for (const py of PYTHON_CANDIDATES) {
461
+ try {
462
+ execSync(`${py} --version`, { stdio: 'ignore' });
463
+ return py;
464
+ } catch (e) { /* try the next interpreter */ }
465
+ }
466
+ return null;
467
+ }
468
+
469
+ // Case/separator normalization uses the PLATFORM'S OWN path.relative: win32
470
+ // folds case and separators; forcing win32 semantics on POSIX would fold
471
+ // genuinely distinct paths (a false positive). One helper guards BOTH the
472
+ // uninstall attribution and the marker lookup — a naive prefix/string match
473
+ // misses `c:\` vs `C:\`, which on attribution re-manufactures the dead hook
474
+ // and on the marker re-wires against a standing opt-out.
475
+ function pathsEqual(a, b) {
476
+ try {
477
+ return path.relative(String(a), String(b)) === '';
478
+ } catch (e) {
479
+ return false;
480
+ }
481
+ }
482
+ function pathInside(child, parent) {
483
+ try {
484
+ const rel = path.relative(String(parent), String(child));
485
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
486
+ } catch (e) {
487
+ return false;
488
+ }
489
+ }
490
+
491
+ // The marker lives under the family's agent-global root (the same root the
492
+ // validator's AGENTIC_SDLC_KB_ROOT seam names), read at call time so the test
493
+ // battery's stubbed env applies. Every helper is fail-open: a broken marker
494
+ // must never break an install, and a failed marker write never undoes a wire.
495
+ function orientMarkerPath() {
496
+ const root = process.env.AGENTIC_SDLC_KB_ROOT
497
+ || path.join(os.homedir(), '.agentic-sdlc');
498
+ return path.join(root, 'orient-hook-wired');
499
+ }
500
+ function orientMarkerTargets() {
501
+ try {
502
+ return fs.readFileSync(orientMarkerPath(), 'utf8')
503
+ .split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
504
+ } catch (e) {
505
+ return [];
506
+ }
507
+ }
508
+ function orientMarkerHas(target) {
509
+ return orientMarkerTargets().some((t) => pathsEqual(t, target));
510
+ }
511
+ function orientMarkerAdd(target) {
512
+ try {
513
+ if (orientMarkerHas(target)) return;
514
+ fs.mkdirSync(path.dirname(orientMarkerPath()), { recursive: true });
515
+ fs.appendFileSync(orientMarkerPath(), target + String.fromCharCode(10), 'utf8');
516
+ } catch (e) { /* fail-open */ }
517
+ }
518
+ function orientMarkerRemove(target) {
519
+ try {
520
+ const rest = orientMarkerTargets().filter((t) => !pathsEqual(t, target));
521
+ if (rest.length) {
522
+ fs.writeFileSync(orientMarkerPath(), rest.join(String.fromCharCode(10)) + String.fromCharCode(10), 'utf8');
523
+ } else {
524
+ fs.rmSync(orientMarkerPath(), { force: true });
525
+ }
526
+ } catch (e) { /* fail-open */ }
527
+ }
528
+
529
+ // Wire the machine-global hook into client.home/settings.json — exactly ONE
530
+ // file (user level has no local split; the path is machine-local by nature).
531
+ // Codes follow the wireOrientHook contract, plus `opted-out`.
532
+ function wireGlobalOrientHook(options) {
533
+ const client = options.client;
534
+ const python = options.python;
535
+
536
+ const validator = path.join(skillTarget(client), 'scripts', ORIENT_ENTRY_POINT);
537
+ if (!fs.existsSync(validator)) return { code: 'no-validator', validator };
538
+ if (!python) return { code: 'no-python', validator };
539
+ if (ORIENT_UNSAFE_IN_PATH.test(validator)) return { code: 'unsafe-path', validator };
540
+ const command = orientHookCommand(python, validator, false);
541
+
542
+ const target = path.join(client.home, 'settings.json');
543
+ const state = orientSettingsState(target);
544
+ if (!state.ok) return { code: 'malformed', target, why: state.why, command };
545
+
546
+ const existing = findOrientHook(state.settings);
547
+ if (existing) {
548
+ const found = orientHookValidator(existing);
549
+ if (found === null || !path.isAbsolute(found)) {
550
+ // No cwd exists at user level, so an unresolvable token is "cannot
551
+ // tell" — left alone, never remembered as ours.
552
+ return { code: 'unverifiable', target, existing, command };
553
+ }
554
+ if (fs.existsSync(found)) {
555
+ // A hand-wired live hook, once removed, deserves the same respect as
556
+ // one this installer wrote: remember the target.
557
+ orientMarkerAdd(target);
558
+ return { code: 'already', target, command: existing };
559
+ }
560
+ return { code: 'broken', target, existing, command };
561
+ }
562
+
563
+ if (orientMarkerHas(target)) return { code: 'opted-out', target };
564
+
565
+ const settings = state.settings;
566
+ if (!settings.hooks) settings.hooks = {};
567
+ if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
568
+ settings.hooks.SessionStart.push({
569
+ hooks: [{
570
+ type: 'command',
571
+ command,
572
+ timeout: ORIENT_HOOK_TIMEOUT,
573
+ statusMessage: 'Loading docs orientation...',
574
+ }],
575
+ });
576
+ try {
577
+ fs.mkdirSync(path.dirname(target), { recursive: true });
578
+ fs.writeFileSync(target, JSON.stringify(settings, null, 2) + String.fromCharCode(10), 'utf8');
579
+ } catch (e) {
580
+ return { code: 'write-failed', target, command, error: e.message };
581
+ }
582
+ orientMarkerAdd(target);
583
+ return { code: 'wired', target, command };
584
+ }
585
+
586
+ // Uninstall symmetry: removing the skill dirs would leave any global hook
587
+ // naming them wired-and-DEAD. So, across EVERY settings target the marker
588
+ // remembers (a hook wired under CLAUDE_CONFIG_DIR=/x must be cleaned from a
589
+ // shell without that env) unioned with the current client.home, remove ONLY
590
+ // the SessionStart entries whose validator token resolves INTO a root being
591
+ // removed. Foreign or unverifiable entries are never touched. The marker line
592
+ // is cleared ONLY when an entry was actually removed — when the user had
593
+ // already removed the hook, the line stays and the opt-out survives uninstall,
594
+ // reinstall, and sibling-lens uninstalls.
595
+ function removeGlobalOrientHooks(removedRoots) {
596
+ const targets = [];
597
+ const claude = CLIENTS.find((c) => c.key === 'claude');
598
+ if (claude) targets.push(path.join(claude.home, 'settings.json'));
599
+ for (const t of orientMarkerTargets()) {
600
+ if (!targets.some((x) => pathsEqual(x, t))) targets.push(t);
601
+ }
602
+ const removedFrom = [];
603
+ for (const target of targets) {
604
+ try {
605
+ const state = orientSettingsState(target);
606
+ if (!state.ok || !fs.existsSync(target)) continue;
607
+ const hooks = state.settings.hooks;
608
+ if (!hooks || !Array.isArray(hooks.SessionStart)) continue;
609
+ let removedHere = false;
610
+ const ours = (h) => {
611
+ const cmd = h && typeof h.command === 'string' ? h.command : '';
612
+ if (!(ORIENT_ENTRY_POINTS.some((n) => cmd.includes(n)) && / orient(\s|$)/.test(cmd))) return false;
613
+ const tok = orientHookValidator(cmd);
614
+ if (tok === null || !path.isAbsolute(tok)) return false; // cannot attribute: keep
615
+ return removedRoots.some((root) => pathInside(tok, root));
616
+ };
617
+ const kept = [];
618
+ for (const group of hooks.SessionStart) {
619
+ if (!group || typeof group !== 'object') { kept.push(group); continue; }
620
+ if (Array.isArray(group.hooks)) {
621
+ const inner = group.hooks.filter((h) => {
622
+ if (ours(h)) { removedHere = true; return false; }
623
+ return true;
624
+ });
625
+ if (inner.length) kept.push({ ...group, hooks: inner });
626
+ else if (inner.length !== group.hooks.length) { /* emptied: prune */ }
627
+ else kept.push(group);
628
+ } else if (ours(group)) {
629
+ removedHere = true; // direct-object shape: prune
630
+ } else {
631
+ kept.push(group);
632
+ }
633
+ }
634
+ if (!removedHere) continue;
635
+ state.settings.hooks.SessionStart = kept;
636
+ if (!kept.length) delete state.settings.hooks.SessionStart;
637
+ if (state.settings.hooks && !Object.keys(state.settings.hooks).length) {
638
+ delete state.settings.hooks;
639
+ }
640
+ fs.writeFileSync(target, JSON.stringify(state.settings, null, 2) + String.fromCharCode(10), 'utf8');
641
+ orientMarkerRemove(target);
642
+ removedFrom.push(target);
643
+ } catch (e) { /* fail-open per target: never abort an uninstall */ }
644
+ }
645
+ return removedFrom;
419
646
  }
420
647
 
421
648
  module.exports = {
@@ -428,6 +655,10 @@ module.exports = {
428
655
  clientDetected,
429
656
  skillTarget,
430
657
  wireOrientHook,
658
+ wireGlobalOrientHook,
659
+ removeGlobalOrientHooks,
660
+ detectPython,
661
+ pathsEqual,
431
662
  orientHookCommand,
432
663
  copyRecursive,
433
664
  loadTemplates,
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const fs = require('fs');
4
- const { SKILL_SOURCE, CLIENTS, clientDetected, skillTarget, copyRecursive } = require('./lib');
4
+ const {
5
+ SKILL_SOURCE, CLIENTS, clientDetected, skillTarget, copyRecursive,
6
+ wireGlobalOrientHook, detectPython,
7
+ } = require('./lib');
5
8
 
6
9
  function installSkill(client) {
7
10
  if (!fs.existsSync(SKILL_SOURCE)) {
@@ -29,7 +32,36 @@ for (const client of CLIENTS) {
29
32
  if (clientDetected(client)) {
30
33
  console.log(`✅ Detected: ${client.label}`);
31
34
  detected = true;
32
- installSkill(client);
35
+ const installed = installSkill(client);
36
+ // F-042: wire the machine-global orientation hook — the install IS the
37
+ // consent, so no question is ever asked. Claude Code only (the one client
38
+ // whose hook shape a fixture pins); removal is a standing opt-out the
39
+ // marker remembers. A convenience must never fail an npm install: wrapped.
40
+ if (client.key === 'claude' && installed) {
41
+ try {
42
+ const r = wireGlobalOrientHook({ client, python: detectPython() });
43
+ if (r.code === 'wired') {
44
+ console.log('🔗 Session orientation wired for every project on this machine');
45
+ console.log(` (${r.target}).`);
46
+ console.log(' Agents now start oriented on any project with a docs root; projects');
47
+ console.log(' without one see nothing. To opt out, remove that hook entry — it');
48
+ console.log(' will not be re-added.');
49
+ } else if (r.code === 'broken') {
50
+ console.log('⚠️ A session-orientation hook exists but the validator it names does');
51
+ console.log(' not resolve, so it has been emitting nothing. Correct its command to:');
52
+ console.log(` ${r.command}`);
53
+ } else if (r.code === 'no-python') {
54
+ console.log('ℹ️ Python not found: session orientation not wired (wire it later per');
55
+ console.log(' ENFORCEMENT.md §4).');
56
+ } else if (r.code === 'malformed' || r.code === 'write-failed'
57
+ || r.code === 'unsafe-path') {
58
+ console.log(`⚠️ Session orientation not wired (${r.code}). Wire it by hand per`);
59
+ console.log(' ENFORCEMENT.md §4.');
60
+ }
61
+ // already / opted-out / unverifiable / no-validator: silent — an
62
+ // install must not nag (the opt-out is the user's standing choice).
63
+ } catch (e) { /* never fail the install for a convenience */ }
64
+ }
33
65
  }
34
66
  }
35
67
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const fs = require('fs');
4
- const { CLIENTS, skillTarget } = require('./lib');
4
+ const { CLIENTS, skillTarget, removeGlobalOrientHooks } = require('./lib');
5
5
 
6
6
  function removeSkill(client) {
7
7
  const target = skillTarget(client);
@@ -14,4 +14,17 @@ function removeSkill(client) {
14
14
  }
15
15
  }
16
16
 
17
+ // F-042: BEFORE the skill dirs go, remove the global orientation hooks that
18
+ // name them — otherwise every future session carries a hook whose validator no
19
+ // longer exists, the wired-and-DEAD state the check note calls the worst of
20
+ // the three. Surgical (only entries attributable to the removed dirs; the
21
+ // marker's opt-out lines survive when nothing was removed) and fail-open (an
22
+ // uninstall must never abort on a settings file).
23
+ try {
24
+ const removedFrom = removeGlobalOrientHooks(CLIENTS.map(skillTarget));
25
+ for (const target of removedFrom) {
26
+ console.log(`🧹 Removed the session-orientation hook entry (${target}).`);
27
+ }
28
+ } catch (e) { /* never abort an uninstall for a convenience */ }
29
+
17
30
  CLIENTS.forEach(removeSkill);
@@ -79,6 +79,37 @@ that cannot be placed in a command safely, or a settings file it cannot write. `
79
79
  prints which case applied, per client — a silent skip is what let "documented default
80
80
  that nobody installs" survive in the first place.
81
81
 
82
+ **Older and agent-bootstrapped projects are silently unwired -- `check` now tells you (F-041).**
83
+ `init` wires the hook at init time only: a project initialized before F-036, or whose
84
+ docs root was bootstrapped by the agent (which runs `index`, never `init`), never
85
+ received it retroactively -- and until F-041 nothing said so. `check` now prints, right after its summary line, a
86
+ one-line `[note]` when no orientation hook is detectable (`.claude/settings.json`,
87
+ `.claude/settings.local.json`, `.codex/hooks.json`), and a DISTINCT note when a hook is
88
+ wired but the validator it names does not resolve (the wired-and-DEAD state below --
89
+ silence there would bless the worst of the three states). The notes are informational
90
+ only: never the exit code, never a `validate` warning -- CI (`validate --strict`) does
91
+ not see them, because the wired hook legitimately lives in the git-ignored
92
+ `settings.local.json`. Known residuals, accepted as honest: a client with no hook
93
+ mechanism (Gemini today) keeps the note -- read it as the manual-reads reminder; a
94
+ `.codex/hooks.json` wired in a shape other than the documented one keeps it too.
95
+
96
+ **Installing the package wires the hook machine-wide (F-042).** On Claude Code
97
+ with Python, `npm i -g` (and every update) wires a SessionStart orient hook into
98
+ the USER-level settings (`$CLAUDE_CONFIG_DIR/settings.json` when set, else
99
+ `~/.claude/settings.json`), covering every project on the machine -- `orient` is
100
+ fail-open, so projects without a docs root see nothing. The install itself is
101
+ the consent: no question is asked. **Removing that hook entry is a standing
102
+ opt-out**: a per-target marker (`<agentic-sdlc root>/orient-hook-wired`,
103
+ default `~/.agentic-sdlc/`) remembers each settings file ever wired, and no
104
+ install or update ever re-adds a removed hook; deleting the marker file is the
105
+ re-enable gesture. `npm uninstall` removes the entries it can attribute to the
106
+ removed skill (and only those), so no hook is left pointing at deleted files.
107
+ Notes: the global command carries no `--hybrid` (a machine-global hook cannot
108
+ know per-project mode -- on Hybrid projects only the devPNT pointer line is
109
+ absent); a project wired per-project AND globally prints its orientation twice
110
+ -- remove one of the two entries; `init` reports "covered by the machine-global
111
+ hook" instead of wiring a project-level duplicate.
112
+
82
113
  **Which settings file, and why it is not always the shared one.** The command names a
83
114
  validator, and where that validator lives decides where the hook may be written:
84
115
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: mkt-agentic-sdlc
3
- version: 0.5.1
3
+ version: 0.7.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
@@ -50,7 +50,7 @@ Always classify the request before choosing the process. Declare the chosen leve
50
50
  | Level | Criteria | Required process |
51
51
  |---|---|---|
52
52
  | **E1 - Quick** | Single question or asset feedback: a positioning opinion, copy critique, one channel question | Answer directly. Numbers still need a source or an explicit "assumption" label. No new documents. |
53
- | **E2 - Campaign** | One campaign or one channel plan, bounded budget, existing strategy context | Mini-brief in the message (objective, audience, budget, KPI), targeted research, `mkt_docs/tactics/CAMPAIGN_[name].md`. Evidence ledger discipline applies. |
53
+ | **E2 - Campaign** | One campaign or one channel plan, bounded budget, existing strategy context | Mini-brief in the message (objective, audience, budget, KPI), targeted research, `mkt_docs/tactics/CAMPAIGN_[name].md`. Evidence ledger discipline applies. Updating an existing strategy or campaign doc is a rewrite: full re-read, body to current state -- never an appended delta section. |
54
54
  | **E3 - Full Plan** | Complete marketing plan, go-to-market for a launch, entering a new market, or no prior strategy exists | Full workflow: all nine phases, all gates, full deliverable pack. |
55
55
  | **Research Spike** | Time-boxed market question that reduces uncertainty ("is there demand for X in market Y?") | Outcome in `mkt_docs/spikes/RESEARCH_[topic].md`. Ledger discipline applies. For strategy decisions, feed into E2/E3. |
56
56
 
@@ -165,7 +165,7 @@ Deliverable language: ask for (or infer) the target market's language in Discove
165
165
  - The user approves objectives before strategy. Objectives the user did not set are proposals, and say so.
166
166
 
167
167
  ### 6. Strategy — **REVIEW + USER GATE**
168
- - **Design review gate:** the strategy is reviewed by somebody other than its author before any tactic is executed — a subagent with fresh context, or a declared self-pass when none is available. Follow `review.md`; log the outcome in `audit/reviews/REVIEW_LOG.md` under the docs root. A strategy reviewed only by the person who wrote it is not reviewed.
168
+ - **Design review gate:** the strategy is reviewed by somebody other than its author before any tactic is executed — a subagent with fresh context, or a declared self-pass when no higher rung is usable — a permission-gated rung is asked about, never silently skipped (`review.md`). Follow `review.md`; log the outcome in `audit/reviews/REVIEW_LOG.md` under the docs root. A strategy reviewed only by the person who wrote it is not reviewed.
169
169
  - Draft `STRATEGY.md`: segmentation, targeting choice with rationale, positioning (Dunford components + statement), messaging house.
170
170
  - **Adversarial review** (`review.md`) before the user sees it. Fix or answer every finding.
171
171
  - The user approves the strategy. Hybrid: propose as governed doc, user accepts in devPNT.
@@ -1,96 +1,98 @@
1
- # Subagent Execution Discipline
2
-
3
- Opt-in orchestration for L3 work with an approved design: the orchestrator
4
- drives a `PLAN_[feature].md` through subagents instead of implementing every
5
- task in the same session. Default stays same-session; this is an escalation,
6
- never a requirement.
7
-
8
- ## Trigger
9
-
10
- Only for L3 (an approved E-TDD in Hybrid, or an ANALYSIS Action Plan in
11
- Standalone). Never for L1/L2 — the plan/ledger machinery is overhead a small
12
- change does not need. The plan is always `derived-from` the accepted design:
13
- it is never independently authored, exactly like an E-TDD is never authored
14
- without an E-ISP.
15
-
16
- ## The loop
17
-
18
- 1. `sdlc_check.py plan validate PLAN_[feature].md` — zero-execution schema +
19
- confinement + ledger cross-check. Non-zero exit = **no dispatch**. This is
20
- the hard gate: "no valid plan, no dispatch."
21
- 2. For each task, in plan order:
22
- - Read the task's status from the sidecar ledger
23
- (`PLAN_[feature].ledger.json`). `status: done` (exact sentinel) → skip,
24
- never re-dispatch. Anything else (pending, failed, missing, or a
25
- corrupt-but-parseable entry) → treat as pending and dispatch.
26
- - `sdlc_check.py plan brief PLAN_[feature].md --task <id>` — prints the
27
- task block, the `produces` of prior-order tasks (interfaces), and
28
- `guides` pointers (paths, never pasted content) to stdout.
29
- - Spawn the subagent with that brief as its entire context window.
30
- - Run `task.verify` out of band (the orchestrator executes it — the
31
- validator only ever prints it, never runs it) plus the one-shot review
32
- below.
33
- - Write `{status, verify_result, timestamp}` back to the ledger. The
34
- validator never writes the ledger — single-writer, orchestrator-owned.
35
-
36
- **Guide consumption under dispatch.** Selecting each task's `guides` field IS the
37
- consult trigger (`guides.md` §0) applied at plan-authoring time: the orchestrator
38
- runs the router lookup (project router `ai_docs/reference/INDEX.md` + the agent-KB
39
- router) when populating `guides`. A dispatched context-free subagent does **NOT**
40
- run its own router consult — it reads the guide pointers handed to it in the
41
- brief. The router verdict (Rule Zero) is therefore declared ONCE, by the
42
- orchestrator, when it authors the plan; a dispatched subagent does not declare
43
- one. (Proactive guide-creation stays at closure — the same broad final pass
44
- below — so it needs no separate dispatch hook.)
45
-
46
- ## Model tiers (client-relative, no provider names)
47
-
48
- - Default dispatch: **economy** implementer tier.
49
- - After **two** consecutive `verify_result: fail` on the same task: escalate
50
- to the **deep** tier for the retry (ADR 2026-07-02). Do not escalate on the
51
- first failure — a single fail is often a brief or environment issue, not a
52
- capability gap.
53
-
54
- ## Review slots — one-shot, not iterative
55
-
56
- Exactly three review touches per task, never a loop:
57
-
58
- 1. Inline self-review by the implementer subagent before it reports done
59
- (the standard critical-review pass, not a separate call).
60
- 2. One reviewer pass per task (Hybrid: reuse the devPNT code-review gate;
61
- Standalone: the `review.md` discipline).
62
- 3. One broad final pass over the whole plan at closure, after all tasks are
63
- DONE — catches cross-task drift a per-task review cannot see.
64
-
65
- If a review FAILs, fix and re-run `verify` — that is a normal loop iteration
66
- via the ledger's fail path, not an extra review slot.
67
-
68
- **The scoped re-review is a round INSIDE slot 2 or 3, never a fourth slot.**
69
- `review.md` §Receiving requires every review-driven correction to be
70
- re-reviewed against the correction alone — that round belongs to the slot whose
71
- findings caused it, and is bounded by the same cap of 3 rounds. "Never a loop"
72
- bounds the SLOTS (no unbounded re-reviewing of a task), not the rounds inside
73
- one slot: a fix nobody looked at is the defect the round exists to catch. Slot 1
74
- is the implementer's own pass, not an independent review, so its fixes carry no
75
- re-review round. The ledger records the round on the task's fail path as usual.
76
-
77
- ## Ledger protocol summary
78
-
79
- Read → skip-if-done → dispatch-if-pending → write. The ledger is the only
80
- memory the loop needs across sessions or context compaction: a resumed
81
- orchestrator re-reads it and picks up exactly where it left off, never
82
- re-running a DONE task.
83
-
84
- ## Degradation
85
-
86
- No subagent-spawning tool available → the orchestrator runs each task in the
87
- same session, against the same plan and ledger, with the same one-shot review
88
- slots. No capability is lost, only the parallelism/isolation subagents would
89
- have added.
90
-
91
- ## Hybrid note
92
-
93
- The plan's `derived-from` points at the accepted E-TDD document key. Per-task
94
- review reuses the devPNT independent reviewers (§4.6 code review gate) rather
95
- than restating review doctrine — see `review.md` for the single definition
96
- both modes share.
1
+ # Subagent Execution Discipline
2
+
3
+ Opt-in orchestration for L3 work with an approved design: the orchestrator
4
+ drives a `PLAN_[feature].md` through subagents instead of implementing every
5
+ task in the same session. Default stays same-session; this is an escalation,
6
+ never a requirement.
7
+
8
+ ## Trigger
9
+
10
+ Only for L3 (an approved E-TDD in Hybrid, or an ANALYSIS Action Plan in
11
+ Standalone). Never for L1/L2 — the plan/ledger machinery is overhead a small
12
+ change does not need. The plan is always `derived-from` the accepted design:
13
+ it is never independently authored, exactly like an E-TDD is never authored
14
+ without an E-ISP.
15
+
16
+ ## The loop
17
+
18
+ 1. `sdlc_check.py plan validate PLAN_[feature].md` — zero-execution schema +
19
+ confinement + ledger cross-check. Non-zero exit = **no dispatch**. This is
20
+ the hard gate: "no valid plan, no dispatch."
21
+ 2. For each task, in plan order:
22
+ - Read the task's status from the sidecar ledger
23
+ (`PLAN_[feature].ledger.json`). `status: done` (exact sentinel) → skip,
24
+ never re-dispatch. Anything else (pending, failed, missing, or a
25
+ corrupt-but-parseable entry) → treat as pending and dispatch.
26
+ - `sdlc_check.py plan brief PLAN_[feature].md --task <id>` — prints the
27
+ task block, the `produces` of prior-order tasks (interfaces), and
28
+ `guides` pointers (paths, never pasted content) to stdout.
29
+ - Spawn the subagent with that brief as its entire context window.
30
+ - Run `task.verify` out of band (the orchestrator executes it — the
31
+ validator only ever prints it, never runs it) plus the one-shot review
32
+ below.
33
+ - Write `{status, verify_result, timestamp}` back to the ledger. The
34
+ validator never writes the ledger — single-writer, orchestrator-owned.
35
+
36
+ **Guide consumption under dispatch.** Selecting each task's `guides` field IS the
37
+ consult trigger (`guides.md` §0) applied at plan-authoring time: the orchestrator
38
+ runs the router lookup (project router `ai_docs/reference/INDEX.md` + the agent-KB
39
+ router) when populating `guides`. A dispatched context-free subagent does **NOT**
40
+ run its own router consult — it reads the guide pointers handed to it in the
41
+ brief. The router verdict (Rule Zero) is therefore declared ONCE, by the
42
+ orchestrator, when it authors the plan; a dispatched subagent does not declare
43
+ one. (Proactive guide-creation stays at closure — the same broad final pass
44
+ below — so it needs no separate dispatch hook.)
45
+
46
+ ## Model tiers (client-relative, no provider names)
47
+
48
+ - Default dispatch: **economy** implementer tier.
49
+ - After **two** consecutive `verify_result: fail` on the same task: escalate
50
+ to the **deep** tier for the retry (ADR 2026-07-02). Do not escalate on the
51
+ first failure — a single fail is often a brief or environment issue, not a
52
+ capability gap.
53
+
54
+ ## Review slots — one-shot, not iterative
55
+
56
+ Exactly three review touches per task, never a loop:
57
+
58
+ 1. Inline self-review by the implementer subagent before it reports done
59
+ (the standard critical-review pass, not a separate call).
60
+ 2. One reviewer pass per task (Hybrid: reuse the devPNT code-review gate;
61
+ Standalone: the `review.md` discipline).
62
+ 3. One broad final pass over the whole plan at closure, after all tasks are
63
+ DONE — catches cross-task drift a per-task review cannot see.
64
+
65
+ If a review FAILs, fix and re-run `verify` — that is a normal loop iteration
66
+ via the ledger's fail path, not an extra review slot.
67
+
68
+ **The scoped re-review is a round INSIDE slot 2 or 3, never a fourth slot.**
69
+ `review.md` §Receiving requires every review-driven correction to be
70
+ re-reviewed against the correction alone — that round belongs to the slot whose
71
+ findings caused it, and is bounded by the same cap of 3 rounds. "Never a loop"
72
+ bounds the SLOTS (no unbounded re-reviewing of a task), not the rounds inside
73
+ one slot: a fix nobody looked at is the defect the round exists to catch. Slot 1
74
+ is the implementer's own pass, not an independent review, so its fixes carry no
75
+ re-review round. The ledger records the round on the task's fail path as usual.
76
+
77
+ ## Ledger protocol summary
78
+
79
+ Read → skip-if-done → dispatch-if-pending → write. The ledger is the only
80
+ memory the loop needs across sessions or context compaction: a resumed
81
+ orchestrator re-reads it and picks up exactly where it left off, never
82
+ re-running a DONE task.
83
+
84
+ ## Degradation
85
+
86
+ No subagent-spawning tool available — absent, or permission-gated with no grant
87
+ in context (`review.md`'s gated-rung stop owns the asking; task dispatch never
88
+ asks; the broad final pass IS `review.md` moment 2 and asks there) → the
89
+ orchestrator runs each task in the same session, against the same plan and ledger,
90
+ with the same one-shot review slots. No capability is lost, only the parallelism/isolation subagents would
91
+ have added.
92
+
93
+ ## Hybrid note
94
+
95
+ The plan's `derived-from` points at the accepted E-TDD document key. Per-task
96
+ review reuses the devPNT independent reviewers (§4.6 code review gate) rather
97
+ than restating review doctrine — see `review.md` for the single definition
98
+ both modes share.
@@ -12,7 +12,7 @@ Two moments, and they review different things:
12
12
  |---|---|---|---|
13
13
  | **1. Design review** | End of Phase 3 — **before any implementation** | the ANALYSIS (Standalone) / the `E-ISP`+`E-TDD` (Hybrid) | L3 |
14
14
  | **1b. Late arrival** | Work that became L3 *after* code existed — an L1/L2 reclassified mid-flight, or a design increment on a feature already implemented — runs moment 1 **now**, before any further implementation, logged `design (late)` | same | L3 |
15
- | **2. Closure review** | Phase 5, before DONE | the actual diff, against that approved design | L2 / L3 — the L2 row is optional |
15
+ | **2. Closure review** | Phase 5, before DONE | the actual diff, against that approved design | L2 / L3 — the L2 closure review is optional; every review that runs logs its row |
16
16
 
17
17
  **Why the design review is its own moment, and not a nicety.** The closure review
18
18
  can only tell you the code matches the design; it cannot tell you the design was
@@ -30,17 +30,56 @@ blind to it, which is why independence, not effort, is what this gate buys.
30
30
  a SELF-CONTAINED prompt — the reviewer session has no other context, which is
31
31
  exactly what makes it independent.
32
32
  3. **A declared self-pass** — a separate, explicitly adversarial pass by you,
33
- against the same checklist. **Rung 3 is illegitimate wherever rung 1 or 2
34
- exists**: on a client with a subagent facility or a one-shot CLI, descending to
35
- it is choosing zero independence, which is the one thing this gate buys. When
36
- you do use it, the log row must carry *why* — `self-pass (declared; no subagent
37
- facility on this client)` — not merely that you did. A rung named without its
33
+ against the same checklist. **Rung 3 is illegitimate wherever a higher rung
34
+ is usable**: on a client with a working subagent facility or one-shot CLI,
35
+ descending to it is choosing zero independence, which is the one thing this
36
+ gate buys. A gated rung the user DECLINED is not usable — see *The gated
37
+ rung* below; a gated rung nobody asked about is not declined (unattended is
38
+ its own case below). When
39
+ you do use it, the log row must carry *why*, in the reason words below —
40
+ `self-pass (declared; absent — the client has no such facility)` — not merely
41
+ that you did. A rung named without its
38
42
  reason is indistinguishable from a rung chosen for convenience.
39
43
 
40
44
  Rung 3 stays in the ladder deliberately: it is what keeps the methodology
41
45
  completable with no network, no account and no subagent facility. It is a floor,
42
46
  never a default.
43
47
 
48
+ **The gated rung — present is not absent (F-038).** A rung that exists behind a
49
+ standing policy or instruction forbidding its use absent a user request is
50
+ **permission-gated**, not unavailable. An interactive per-call approval prompt is
51
+ NOT this: answering the prompt IS the grant. With an ungated rung usable — rung 1
52
+ free, or a one-shot CLI that works (try it, or show it failed) — the prohibition
53
+ above is already satisfiable and no stop fires (the row still carries
54
+ `gated, pre-empted` when a gated rung sat above the one that ran); a gated
55
+ rung 2 is not "usable" for this clause, it joins the question instead. When the best rung is gated and no
56
+ ungated rung works, the gate STOPS and asks: descending to rung 3 on silence is
57
+ illegal while a user is reachable. The stop is doctrine-mandated (legal by mandate,
58
+ `elicitation.md` §Blocking is reserved) and carries that file's five-bullet
59
+ blocking form — its closed-list exemption covers this file's round-cap hand-over
60
+ only, not this stop. The question offers the gated rung(s) against the fallback, with what each
61
+ buys; states each higher rung's status (tried / unusable / gated) with the
62
+ standing instruction quoted and "no grant visible in my current context"; says
63
+ why no assumption survives (assuming either way writes a false log row); says
64
+ why it is the USER'S call — their tokens (~130-175k per deep review, measured
65
+ 2026-08) against a benefit only they can price, stated with what independence
66
+ last bought; and names what stays blocked — which reviewer runs, nothing else.
67
+ **No grant memory exists**: the answer holds while the conversation does, and an
68
+ agent that cannot recall a grant asks again — one question per gate per intact
69
+ context; re-asking is a smaller defect than a false row. Unattended
70
+ (`elicitation.md`'s Unattended path — the user is not reachable), no question is
71
+ emitted: rung 2 is still owed a try, then rung 3 runs with its reason logged.
72
+
73
+ **The reason words.** A below-rung-1 row carries why the rung(s) above did not
74
+ run; a rung-1 row owes nothing. `absent` — the client has no such facility (a
75
+ claim about the client, never about a policy); `gated, declined` — usable only
76
+ with the user's assent at this gate, and the user withheld it (a standing policy
77
+ answered no, or a per-call prompt denied); `gated, unattended` — a standing policy
78
+ gates it and no user is reachable to ask; `gated, pre-empted` — gated, nothing
79
+ asked: an ungated lower rung ran the review instead. `gated` always appears with
80
+ its outcome. The trigger term "permission-gated" scopes the STOP; the row word
81
+ `gated` additionally covers a denied per-call prompt, which never triggers one.
82
+
44
83
  Use a different model from the author's where the client allows it.
45
84
 
46
85
  **Rounds are capped at 3.** FAIL → revise → re-review. If findings still stand
@@ -714,6 +714,11 @@ def main(argv=None):
714
714
  f"({len(rep.warnings)} warning(s))" if rep.warnings else f"[OK] {args.command} clean")
715
715
  else:
716
716
  print(f"[FAIL] {args.command}: {len(rep.errors)} error(s), {len(rep.warnings)} warning(s)")
717
+ if args.command == "check":
718
+ # This overlay REPLACES the spine's cmd_check, so the F-041 wiring note
719
+ # must be re-attached here: it is check-layer behaviour, whoever owns
720
+ # the check (fail-open and informational -- never the exit code).
721
+ sdlc_core.print_orient_hook_note(root)
717
722
  return code
718
723
 
719
724
 
@@ -1646,6 +1646,143 @@ def cmd_mark(root, paths):
1646
1646
  return 0
1647
1647
 
1648
1648
 
1649
+ # ------------------------------------------------- orientation-hook wiring
1650
+ # F-041: read-only mirror of the installer-side detection in lib.js
1651
+ # (findOrientHook + orientHookValidator). The JS owns WRITING hooks, with its
1652
+ # injection guards; this side only DETECTS, at `check`, so a project whose
1653
+ # sessions start unoriented hears about it in-project (ENFORCEMENT par.4: the
1654
+ # hook is wired at init only -- pre-F-036 and agent-bootstrapped projects never
1655
+ # got it, and a wired-but-dead hook is "the worst of the three states").
1656
+ # The file list is a SUPERSET of the JS one: .codex/hooks.json is documented in
1657
+ # ENFORCEMENT par.4 but never written by init. Pinned equal on both sides:
1658
+ # the entry-point names, and (F-042) the user-settings path derivation --
1659
+ # CLAUDE_CONFIG_DIR when set, else <home>/.claude -- empty-string env falsy
1660
+ # on both sides.
1661
+ ORIENT_HOOK_ENTRY_POINTS = ("sdlc_check.py", "mkt_check.py")
1662
+ ORIENT_HOOK_CONFIG_FILES = (".claude/settings.json",
1663
+ ".claude/settings.local.json",
1664
+ ".codex/hooks.json")
1665
+ ORIENT_HOOK_CONFIG_CAP = 1024 * 1024 # settings are tiny; cap pathological files
1666
+ _ORIENT_HOOK_CMD_RE = re.compile(r" orient(\s|$)")
1667
+ _ORIENT_HOOK_TOKEN_RE = re.compile(r'"([^"]*)"|(\S+)')
1668
+
1669
+
1670
+ def _orient_entries_in(p):
1671
+ """Yield the SessionStart commands invoking an orient entry point from ONE
1672
+ client config file. Fail-open: an unreadable, oversized, malformed or
1673
+ directory-shaped file contributes nothing. Reads are utf-8-sig -- a BOM'd
1674
+ settings.json (PowerShell/Notepad) must not read as unwired (declared
1675
+ divergence from Node's JSON.parse)."""
1676
+ try:
1677
+ if not p.is_file() or p.stat().st_size > ORIENT_HOOK_CONFIG_CAP:
1678
+ return
1679
+ data = json.loads(p.read_text(encoding="utf-8-sig"))
1680
+ except Exception:
1681
+ return
1682
+ if not isinstance(data, dict):
1683
+ return
1684
+ hooks = data.get("hooks")
1685
+ groups = hooks.get("SessionStart") if isinstance(hooks, dict) else None
1686
+ if groups is None:
1687
+ groups = data.get("SessionStart") # the .codex/hooks.json shape
1688
+ if not isinstance(groups, list):
1689
+ return
1690
+ for group in groups:
1691
+ if not isinstance(group, dict):
1692
+ continue
1693
+ inner = group.get("hooks")
1694
+ for h in inner if isinstance(inner, list) else [group]:
1695
+ cmd = h.get("command") if isinstance(h, dict) else None
1696
+ if (isinstance(cmd, str)
1697
+ and any(n in cmd for n in ORIENT_HOOK_ENTRY_POINTS)
1698
+ and _ORIENT_HOOK_CMD_RE.search(cmd)):
1699
+ yield cmd
1700
+
1701
+
1702
+ def _orient_user_settings_path():
1703
+ """The machine-global settings file (F-042 wires it at install time).
1704
+
1705
+ Read at CALL time -- a DECLARED divergence from AGENTIC_SDLC_KB_ROOT's
1706
+ import-time read: unittest imports this module once before any test runs,
1707
+ so an import-time seam would be frozen before the matrix could set it.
1708
+ AGENTIC_SDLC_USER_SETTINGS is the test-isolation seam (python-side only);
1709
+ the production derivation mirrors lib.js: CLAUDE_CONFIG_DIR || ~/.claude."""
1710
+ seam = os.environ.get("AGENTIC_SDLC_USER_SETTINGS")
1711
+ if seam:
1712
+ return Path(seam)
1713
+ cfg = os.environ.get("CLAUDE_CONFIG_DIR")
1714
+ return (Path(cfg) if cfg else Path.home() / ".claude") / "settings.json"
1715
+
1716
+
1717
+ def _orient_hook_commands(root):
1718
+ """Yield (command, at_user_level) across every known client config: the
1719
+ three project-relative files, then the machine-global user settings --
1720
+ the file the installer wires (F-042), without which a fixed machine's
1721
+ projects would read as unwired forever."""
1722
+ for rel in ORIENT_HOOK_CONFIG_FILES:
1723
+ for cmd in _orient_entries_in(Path(root) / rel):
1724
+ yield cmd, False
1725
+ for cmd in _orient_entries_in(_orient_user_settings_path()):
1726
+ yield cmd, True
1727
+
1728
+
1729
+ def orient_hook_state(root):
1730
+ """'wired' | 'dead' | 'unwired'.
1731
+
1732
+ A matching entry whose tokens name no validator is "cannot tell" and counts
1733
+ as wired (the lib.js contract: never report broken on cannot-tell). 'dead'
1734
+ requires every matching entry to name a validator that does not resolve:
1735
+ the client runs ALL SessionStart hooks -- project-level AND user-level
1736
+ merge -- so one live entry at either level still orients the session
1737
+ (any-resolves aggregation across levels). Relative tokens resolve against
1738
+ the project root (the vendored-validator convention); a USER-level relative
1739
+ token has no cwd to resolve against and is treated as cannot-tell."""
1740
+ matched = False
1741
+ for cmd, at_user_level in _orient_hook_commands(root):
1742
+ matched = True
1743
+ token = None
1744
+ for m in _ORIENT_HOOK_TOKEN_RE.finditer(cmd):
1745
+ tok = m.group(1) if m.group(1) is not None else m.group(2)
1746
+ if any(tok.endswith(n) for n in ORIENT_HOOK_ENTRY_POINTS):
1747
+ token = tok
1748
+ break
1749
+ if token is None:
1750
+ return "wired"
1751
+ tp = Path(token)
1752
+ if not tp.is_absolute():
1753
+ if at_user_level:
1754
+ return "wired" # cannot tell: no cwd exists at user level
1755
+ tp = Path(root) / tp
1756
+ if tp.exists():
1757
+ return "wired"
1758
+ return "dead" if matched else "unwired"
1759
+
1760
+
1761
+ def print_orient_hook_note(root):
1762
+ """Print the FS-A wiring note (unwired / dead), or nothing when wired.
1763
+
1764
+ Informational only, by contract: never an exit code, never a validate
1765
+ warning (--strict CI must not fail on a hook that legitimately lives in
1766
+ git-ignored settings.local.json). Called by the spine's cmd_check AND by
1767
+ an overlay that replaces check with its own pipeline (the marketing lens
1768
+ does): the note is check-layer behaviour, whoever owns the check."""
1769
+ try:
1770
+ state = orient_hook_state(root)
1771
+ if state == "unwired":
1772
+ print("[note] agent sessions start without automatic orientation on this "
1773
+ "machine (no session-orientation hook found at project or user level). "
1774
+ "Fix once: update or reinstall the skill npm package (on Claude Code "
1775
+ "with Python it wires the hook machine-wide), or wire it per "
1776
+ "ENFORCEMENT.md par.4")
1777
+ elif state == "dead":
1778
+ print("[note] a session-orientation hook is configured but points at a "
1779
+ "validator that no longer exists, so sessions start unoriented. Fix: "
1780
+ "update or reinstall the skill npm package, or correct the command. "
1781
+ "Details: ENFORCEMENT.md par.4")
1782
+ except Exception:
1783
+ pass # detection must never break check
1784
+
1785
+
1649
1786
  def cmd_check(root, strict=False, hybrid=False):
1650
1787
  print("===== validate =====")
1651
1788
  rc_v = cmd_validate(root, strict=strict, hybrid=hybrid)
@@ -1653,6 +1790,7 @@ def cmd_check(root, strict=False, hybrid=False):
1653
1790
  rc_s = cmd_stale(root, hybrid=hybrid)
1654
1791
  print(f"\ncheck: {'CLEAN' if not (rc_v or rc_s) else 'NOT CLEAN'} "
1655
1792
  f"(validate rc={rc_v}, stale rc={rc_s})")
1793
+ print_orient_hook_note(root)
1656
1794
  return 1 if (rc_v or rc_s) else 0
1657
1795
 
1658
1796
 
@@ -505,7 +505,7 @@ adds values to the existing columns rather than a second table.
505
505
  | date | doc_key | tier | reviewer | findings_raised | findings_real | verdict | revise_rounds |
506
506
  |---|---|---|---|---|---|---|---|
507
507
  | 2026-06-11 | ANALYSIS_login_sso.md | design | subagent (opus, fresh ctx) | 4 | 3 | FAIL → PASS | 2 |
508
- | 2026-06-12 | diff feature/sso-login | closure | self-pass (declared; no subagent facility) | 2 | 2 | PASS with findings → corrections re-reviewed, PASS | 2 |
508
+ | 2026-06-12 | diff feature/sso-login | closure | self-pass (declared; absent) | 2 | 2 | PASS with findings → corrections re-reviewed, PASS | 2 |
509
509
 
510
510
  ## Notes
511
511
  <!-- One short paragraph per review that found something worth remembering: what
@@ -520,9 +520,17 @@ and `closure` (Standalone); `deep`, `light`, `code`, `guide`, `vision` (devPNT g
520
520
  and the Vision blind check). The validator reads this column by its header name, so
521
521
  extra or reordered columns are fine — but the header must say `tier`. `reviewer`
522
522
  records the realization actually used — fresh subagent, one-shot client run, or a
523
- **declared** self-pass. Writing `self-pass` where independence was unavailable is
524
- honest; writing nothing, or implying independence you did not have, is the failure
525
- this column exists to prevent. `findings_real` is how many raised findings survived
523
+ **declared** self-pass — and, for any rung below rung 1, WHY the rung(s) above did
524
+ not run, in the ladder's reason words (`review.md`): `absent` — the client has no
525
+ such facility (a claim about the client, never about a policy); `gated, declined` —
526
+ usable only with the user's assent at this gate, withheld (a standing policy
527
+ answered no, or a per-call prompt denied); `gated, unattended` — a standing policy
528
+ gates it, no user reachable; `gated, pre-empted` — gated, nothing asked, an ungated
529
+ lower rung ran the review. A rung-1 row owes nothing. `gated` here is the ROW
530
+ word — it also covers a denied per-call prompt, which never triggers `review.md`'s
531
+ stop. Writing `self-pass` with its
532
+ true reason is honest; writing nothing, or implying independence you did not have,
533
+ is the failure this column exists to prevent. `findings_real` is how many raised findings survived
526
534
  triage: over time it is the only evidence of whether the gate earns its cost.
527
535
  `revise_rounds` counts **review rounds**, not fix cycles: the first review is round 1
528
536
  and every scoped re-review adds one. A review that produced findings which were then