create-principles-disciple 1.147.6 → 1.147.8

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.
Files changed (97) hide show
  1. package/_release/product-identity.json +1 -1
  2. package/core/dist/host/host-installer.d.ts +15 -0
  3. package/core/dist/host/host-installer.d.ts.map +1 -1
  4. package/core/dist/host/host-installer.js.map +1 -1
  5. package/core/dist/runtime-v2/feature-flags/feature-flag-contract.js +1 -1
  6. package/core/dist/runtime-v2/feature-flags/feature-flag-contract.js.map +1 -1
  7. package/core/dist/runtime-v2/feature-flags/feature-flag-lifecycle.js +2 -2
  8. package/core/dist/runtime-v2/feature-flags/feature-flag-lifecycle.js.map +1 -1
  9. package/core/dist/runtime-v2/internalization/artificer-run-correlation.d.ts +1 -1
  10. package/core/dist/runtime-v2/internalization/artificer-run-correlation.js +1 -1
  11. package/core/dist/runtime-v2/internalization/consumer-evidence-preflight.d.ts +1 -1
  12. package/core/dist/runtime-v2/internalization/evidence-readiness.js +1 -1
  13. package/core/dist/runtime-v2/internalization/owner-evidence-recovery.d.ts +1 -1
  14. package/core/dist/runtime-v2/pain-recall-capability.d.ts.map +1 -1
  15. package/core/dist/runtime-v2/pain-recall-capability.js +53 -2
  16. package/core/dist/runtime-v2/pain-recall-capability.js.map +1 -1
  17. package/core/dist/runtime-v2/pain-recall-read-model.d.ts.map +1 -1
  18. package/core/dist/runtime-v2/pain-recall-read-model.js +60 -5
  19. package/core/dist/runtime-v2/pain-recall-read-model.js.map +1 -1
  20. package/core/dist/runtime-v2/principle-reuse/reuse-domain.d.ts +1 -1
  21. package/core/dist/runtime-v2/principle-reuse/reuse-domain.js +1 -1
  22. package/core/dist/runtime-v2/principle-reuse/reuse-proposal.d.ts +1 -1
  23. package/core/dist/runtime-v2/principle-reuse/reuse-proposal.js +1 -1
  24. package/core/dist/runtime-v2/principle-reuse/reuse-retrieval.d.ts +1 -1
  25. package/core/dist/runtime-v2/principle-reuse/reuse-retrieval.js +1 -1
  26. package/core/dist/runtime-v2/signal-collector/keyword-stage.d.ts.map +1 -1
  27. package/core/dist/runtime-v2/signal-collector/keyword-stage.js +35 -0
  28. package/core/dist/runtime-v2/signal-collector/keyword-stage.js.map +1 -1
  29. package/core/dist/runtime-v2/trajectory-schema.d.ts +1 -1
  30. package/core/package.json +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +6 -29
  33. package/dist/index.js.map +1 -1
  34. package/dist/install-summary.d.ts +38 -0
  35. package/dist/install-summary.d.ts.map +1 -0
  36. package/dist/install-summary.js +115 -0
  37. package/dist/install-summary.js.map +1 -0
  38. package/dist/installer.d.ts +36 -7
  39. package/dist/installer.d.ts.map +1 -1
  40. package/dist/installer.js +232 -54
  41. package/dist/installer.js.map +1 -1
  42. package/dist/installers/workbuddy-host-installer.d.ts +51 -17
  43. package/dist/installers/workbuddy-host-installer.d.ts.map +1 -1
  44. package/dist/installers/workbuddy-host-installer.js +510 -186
  45. package/dist/installers/workbuddy-host-installer.js.map +1 -1
  46. package/dist/prompts.js +1 -1
  47. package/dist/prompts.js.map +1 -1
  48. package/host-runtime/dist/governance-signal-admission.d.ts +2 -2
  49. package/host-runtime/dist/governance-signal-admission.js +2 -6
  50. package/host-runtime/package.json +1 -1
  51. package/package.json +3 -3
  52. package/pd-cli/dist/commands/health-mcp.d.ts +19 -0
  53. package/pd-cli/dist/commands/health-mcp.d.ts.map +1 -1
  54. package/pd-cli/dist/commands/health-mcp.js +226 -0
  55. package/pd-cli/dist/commands/health-mcp.js.map +1 -1
  56. package/pd-cli/dist/commands/health.d.ts.map +1 -1
  57. package/pd-cli/dist/commands/health.js +18 -1
  58. package/pd-cli/dist/commands/health.js.map +1 -1
  59. package/pd-cli/dist/commands/mcp-logs-export.d.ts +7 -0
  60. package/pd-cli/dist/commands/mcp-logs-export.d.ts.map +1 -0
  61. package/pd-cli/dist/commands/mcp-logs-export.js +267 -0
  62. package/pd-cli/dist/commands/mcp-logs-export.js.map +1 -0
  63. package/pd-cli/dist/commands/workbuddy-worker.d.ts.map +1 -1
  64. package/pd-cli/dist/commands/workbuddy-worker.js +2 -1
  65. package/pd-cli/dist/commands/workbuddy-worker.js.map +1 -1
  66. package/pd-cli/dist/index.js +35 -0
  67. package/pd-cli/dist/index.js.map +1 -1
  68. package/pd-cli/dist/services/zip-writer.d.ts +6 -0
  69. package/pd-cli/dist/services/zip-writer.d.ts.map +1 -0
  70. package/pd-cli/dist/services/zip-writer.js +97 -0
  71. package/pd-cli/dist/services/zip-writer.js.map +1 -0
  72. package/pd-cli/package.json +1 -1
  73. package/pd-mcp-server/dist/index.d.ts +1 -0
  74. package/pd-mcp-server/dist/index.js +167 -10
  75. package/pd-mcp-server/dist/log-file.d.ts +26 -0
  76. package/pd-mcp-server/dist/log-file.js +312 -0
  77. package/pd-mcp-server/dist/logging.d.ts +2 -1
  78. package/pd-mcp-server/dist/logging.js +25 -14
  79. package/pd-mcp-server/dist/server.js +78 -24
  80. package/pd-mcp-server/dist/version.d.ts +1 -1
  81. package/pd-mcp-server/dist/version.js +1 -1
  82. package/pd-mcp-server/dist/workspace.d.ts +3 -0
  83. package/pd-mcp-server/dist/workspace.js +5 -0
  84. package/pd-mcp-server/package.json +1 -1
  85. package/plugin/dist/bundle.js +274 -273
  86. package/plugin/dist/openclaw.plugin.json +1 -1
  87. package/plugin/mcp/launcher.cjs +561 -0
  88. package/plugin/openclaw.plugin.json +1 -1
  89. package/plugin/package.json +1 -1
  90. package/release-manager/dist/installer.js +232 -54
  91. package/release-manager/dist/installers/workbuddy-host-installer.js +510 -186
  92. package/release-manager/package.json +1 -1
  93. package/workbuddy-adapter/dist/pd-hook.js +3 -2
  94. package/workbuddy-adapter/package.json +1 -1
  95. package/workbuddy-adapter/plugin/.codebuddy-plugin/plugin.json +1 -1
  96. package/workbuddy-adapter/plugin/mcp/launcher.cjs +215 -1
  97. package/workbuddy-adapter/plugin/skills/pd-pain-signal/SKILL.md +4 -0
@@ -418,7 +418,7 @@ export function findWorkbuddyPluginPayload(pluginDir, runtimeAdapterDir) {
418
418
  return { reason: `workbuddy_plugin_control_missing: ${root} must ship scripts/pd-control.cjs for the pd-disable skill.` };
419
419
  }
420
420
  const { version } = manifest;
421
- return { payload: { pluginDir: root, version } };
421
+ return { payload: { pluginDir: root, version, defaultEnabled: manifest.defaultEnabled === true } };
422
422
  }
423
423
  return {
424
424
  reason: `workbuddy_adapter_payload_missing: no WorkBuddy plugin skeleton found (looked for workbuddy-adapter/plugin/.codebuddy-plugin/plugin.json under the installer payload and ${runtimeAdapterDir}). The @principles/workbuddy-adapter component is not part of this installer build.`,
@@ -515,10 +515,7 @@ function removeWorkbuddyFromInstallManifestHosts(manifestPath) {
515
515
  }
516
516
  // ─── Feature flag override (config.yaml line-targeted edit) ─────────────────
517
517
  const FEATURES_LINE = /^features:\s*(?:#.*)?$/;
518
- const FEATURES_EMPTY_MAPPING_LINE = /^features:\s*\{\}\s*(?:#.*)?$/;
519
- const WORKBUDDY_KEY_LINE = /^ {2}host\.workbuddy:\s*(?:#.*)?$/;
520
- const WORKBUDDY_ENABLED_LINE = /^ {4}enabled:\s*(?:true|false)\s*(?:#.*)?$/;
521
- const WORKBUDDY_CATEGORY_LINE = /^ {4}category:(?:\s.*)?(?:#.*)?$/;
518
+ const FEATURES_EMPTY_MAPPING_LINE = /^features:\s*\{\s*\}\s*(?:#.*)?$/;
522
519
  /**
523
520
  * Registry-required category for the host.workbuddy flag entry. SSoT:
524
521
  * packages/principles-core/src/runtime-v2/feature-flags/feature-flag-contract.ts
@@ -533,8 +530,318 @@ function stripYamlComment(line) {
533
530
  const hash = line.indexOf('#');
534
531
  return (hash === -1 ? line : line.slice(0, hash)).trim();
535
532
  }
536
- function isEmptyFeaturesMapping(line) {
537
- return stripYamlComment(line).replace(/\s+/g, '') === 'features:' + String.fromCharCode(123, 125);
533
+ function leadingSpaces(line) {
534
+ return /^ +/.exec(line)?.[0].length ?? 0;
535
+ }
536
+ function escapeRegExp(value) {
537
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
538
+ }
539
+ const FLAG_EDIT_MANUAL_GUIDANCE = 'Edit the governance config.yaml by hand: keep the block-style mapping and change only features.<key>.enabled (or add the key under the top-level features: mapping). The installer refuses whole-file rewrites so Owner comments and unknown fields survive.';
540
+ function failLineEdit(configPath, code, detail) {
541
+ return {
542
+ ok: false,
543
+ reason: `${code}: ${detail} (${configPath}). No write was performed.`,
544
+ nextAction: `${FLAG_EDIT_MANUAL_GUIDANCE} Affected file: ${configPath}.`,
545
+ };
546
+ }
547
+ function inspectFeaturesHeader(lines) {
548
+ for (let index = 0; index < lines.length; index += 1) {
549
+ const line = lines[index];
550
+ if (typeof line !== 'string' || !/^features:/.test(line))
551
+ continue;
552
+ if (FEATURES_LINE.test(line))
553
+ return { index, style: 'block' };
554
+ if (FEATURES_EMPTY_MAPPING_LINE.test(line))
555
+ return { index, style: 'empty' };
556
+ return { index, style: 'flow' };
557
+ }
558
+ return null;
559
+ }
560
+ function findUnsafeYamlConstruct(lines, start) {
561
+ for (let index = start; index < lines.length; index += 1) {
562
+ const line = lines[index];
563
+ if (typeof line !== 'string')
564
+ break;
565
+ const body = stripYamlComment(line);
566
+ if (index > start && body.trim() === '')
567
+ continue;
568
+ if (index > start && !/^\s/.test(line))
569
+ break;
570
+ if (/^\s*<<:/.test(body))
571
+ return { line: index, kind: 'YAML merge key (<<:)' };
572
+ if (/(?:^|\s)&[A-Za-z0-9_]/.test(body))
573
+ return { line: index, kind: 'YAML anchor (&)' };
574
+ if (/(?:^|\s)\*[A-Za-z0-9_]/.test(body))
575
+ return { line: index, kind: 'YAML alias (*)' };
576
+ }
577
+ return null;
578
+ }
579
+ function featuresBlockEnd(lines, start) {
580
+ for (let index = start + 1; index < lines.length; index += 1) {
581
+ const line = lines[index];
582
+ if (typeof line !== 'string')
583
+ return index;
584
+ const body = line.trim();
585
+ if (body === '' || body.startsWith('#'))
586
+ continue;
587
+ if (!/^\s/.test(line))
588
+ return index;
589
+ }
590
+ return lines.length;
591
+ }
592
+ function findKeyLineIndexes(lines, range, flagId) {
593
+ const indexes = [];
594
+ for (let index = range.start + 1; index < range.end; index += 1) {
595
+ const line = lines[index];
596
+ if (typeof line !== 'string')
597
+ break;
598
+ if (stripYamlComment(line).trim() === `${flagId}:`)
599
+ indexes.push(index);
600
+ }
601
+ return indexes;
602
+ }
603
+ /**
604
+ * Detect a flag entry written inline (`flagId: { enabled: true }` or
605
+ * `flagId: [ ... ]`) inside the features block. Such an entry has no
606
+ * independent `enabled:` line to rewrite, so it is refused explicitly rather
607
+ * than reported as an unparsable rewrite after a duplicate key is inserted.
608
+ */
609
+ function findInlineFlagEntry(lines, range, flagId) {
610
+ const pattern = new RegExp(`^${escapeRegExp(flagId)}:\\s*[\\[{]`);
611
+ for (let index = range.start + 1; index < range.end; index += 1) {
612
+ const line = lines[index];
613
+ if (typeof line !== 'string')
614
+ break;
615
+ if (pattern.test(stripYamlComment(line).trim()))
616
+ return index;
617
+ }
618
+ return null;
619
+ }
620
+ function flagBlockEnd(lines, keyIndex, keyIndent) {
621
+ for (let index = keyIndex + 1; index < lines.length; index += 1) {
622
+ const line = lines[index];
623
+ if (typeof line !== 'string')
624
+ return index;
625
+ const body = line.trim();
626
+ if (body === '' || body.startsWith('#'))
627
+ continue;
628
+ if (leadingSpaces(line) <= keyIndent)
629
+ return index;
630
+ }
631
+ return lines.length;
632
+ }
633
+ function childIndentOf(lines, keyIndex, options) {
634
+ for (let index = keyIndex + 1; index < options.end; index += 1) {
635
+ const line = lines[index];
636
+ if (typeof line !== 'string')
637
+ continue;
638
+ const body = line.trim();
639
+ if (body === '' || body.startsWith('#'))
640
+ continue;
641
+ const indent = leadingSpaces(line);
642
+ if (indent > options.keyIndent)
643
+ return indent;
644
+ }
645
+ return options.keyIndent + 2;
646
+ }
647
+ /**
648
+ * Infer the child indentation used by an existing features block, so a
649
+ * missing flag is inserted in the file's own style (2, 4, 6 spaces …) instead
650
+ * of a guessed `key + 2`. Returns null when the block has no nested content to
651
+ * learn from.
652
+ */
653
+ function inferChildIndent(lines, range, keyIndent) {
654
+ let parentIndent = null;
655
+ for (let index = range.start; index < range.end; index += 1) {
656
+ const line = lines[index];
657
+ if (typeof line !== 'string')
658
+ continue;
659
+ const body = line.trim();
660
+ if (body === '' || body.startsWith('#'))
661
+ continue;
662
+ const indent = leadingSpaces(line);
663
+ if (parentIndent !== null && indent > parentIndent)
664
+ return indent;
665
+ parentIndent = indent === keyIndent ? indent : null;
666
+ }
667
+ return null;
668
+ }
669
+ function findFieldIndexes(lines, range, options) {
670
+ const pattern = new RegExp(`^ {${options.indent}}${escapeRegExp(options.field)}:(\\s|$)`);
671
+ const indexes = [];
672
+ for (let index = range.start; index < range.end; index += 1) {
673
+ const line = lines[index];
674
+ if (typeof line !== 'string')
675
+ continue;
676
+ if (pattern.test(line))
677
+ indexes.push(index);
678
+ }
679
+ return indexes;
680
+ }
681
+ function rewriteScalarLine(line, field, value) {
682
+ const match = new RegExp(`^(\\s*)${escapeRegExp(field)}:(\\s*)(.*)$`).exec(line);
683
+ if (!match)
684
+ return null;
685
+ const remainder = match[3] ?? '';
686
+ const hash = remainder.indexOf('#');
687
+ const comment = hash === -1 ? '' : remainder.slice(hash);
688
+ const valuePart = hash === -1 ? remainder : remainder.slice(0, hash);
689
+ const trailing = /\s*$/.exec(valuePart)?.[0] ?? '';
690
+ const rawValue = valuePart.trim();
691
+ if (rawValue === '')
692
+ return null;
693
+ if (/^(?:~|null|Null|NULL)$/.test(rawValue))
694
+ return null;
695
+ const quoted = /^(['"])([^'"]*)(['"])$/.exec(rawValue);
696
+ const quote = quoted ? quoted[1] : '';
697
+ return `${match[1]}${field}:${match[2]}${quote}${value}${quote}${trailing}${comment}`;
698
+ }
699
+ /**
700
+ * Locate `features.<flagId>` in block-style YAML and rewrite ONLY its
701
+ * `enabled` line (plus, when missing, its registry `category` line). Every
702
+ * byte outside the flag block is verified unchanged before writing; anything
703
+ * the editor cannot locate safely fails closed instead of falling back to a
704
+ * whole-file `yaml.dump` (which would drop Owner comments).
705
+ *
706
+ * KNOWN LIMITATIONS (all fail closed, never rewritten; the first two are
707
+ * rejected by the YAML parse that runs before any edit):
708
+ * - tab-based indentation (invalid YAML);
709
+ * - any config that does not parse;
710
+ * - `features:` or the flag entry written in flow style (`{ ... }` / `[ ... ]`);
711
+ * - YAML anchors, aliases or merge keys (`&`, `*`, `<<:`) inside features;
712
+ * - a flag appearing twice, or the same field twice inside one flag block;
713
+ * - a field that is not a scalar (`enabled`/`category` written as a nested map).
714
+ */
715
+ function applyFeatureFlagLineEdit(raw, request) {
716
+ const { flagId, enabled, category, configPath } = request;
717
+ // Preserve the file's own line ending: inserting "\n" into a CRLF config
718
+ // would change every following byte's neighbourhood (mixed EOL).
719
+ const eol = raw.includes('\r\n') ? '\r\n' : '\n';
720
+ const lines = raw.split(eol);
721
+ const header = inspectFeaturesHeader(lines);
722
+ if (header?.style === 'flow') {
723
+ return failLineEdit(configPath, 'config_features_flow_style', 'the top-level features: mapping is written inline (flow style)');
724
+ }
725
+ if (header) {
726
+ const unsafe = findUnsafeYamlConstruct(lines, header.index);
727
+ if (unsafe !== null) {
728
+ return failLineEdit(configPath, 'config_yaml_anchor_or_merge_key', `${unsafe.kind} at line ${unsafe.line + 1}`);
729
+ }
730
+ }
731
+ const featuresStart = header?.index ?? lines.length;
732
+ const featuresEnd = header ? featuresBlockEnd(lines, header.index) : lines.length;
733
+ const featuresRange = { start: featuresStart, end: featuresEnd };
734
+ if (header) {
735
+ const inline = findInlineFlagEntry(lines, featuresRange, flagId);
736
+ if (inline !== null) {
737
+ return failLineEdit(configPath, 'config_flag_flow_style', `features.${flagId} is written inline at line ${inline + 1}`);
738
+ }
739
+ }
740
+ const keyIndexes = header ? findKeyLineIndexes(lines, featuresRange, flagId) : [];
741
+ if (keyIndexes.length > 1) {
742
+ return failLineEdit(configPath, 'config_flag_block_ambiguous', `features.${flagId} appears ${keyIndexes.length} times`);
743
+ }
744
+ let working;
745
+ let allowedStart;
746
+ let allowedEnd;
747
+ if (keyIndexes.length === 1) {
748
+ const [keyIndex] = keyIndexes;
749
+ const keyLine = keyIndex === undefined ? undefined : lines[keyIndex];
750
+ if (keyIndex === undefined || keyLine === undefined) {
751
+ return failLineEdit(configPath, 'config_flag_block_ambiguous', `features.${flagId} could not be located`);
752
+ }
753
+ const keyIndent = leadingSpaces(keyLine);
754
+ const blockEnd = flagBlockEnd(lines, keyIndex, keyIndent);
755
+ const childIndent = childIndentOf(lines, keyIndex, { end: blockEnd, keyIndent });
756
+ working = [...lines];
757
+ const enabledIndexes = findFieldIndexes(working, { start: keyIndex + 1, end: blockEnd }, { field: 'enabled', indent: childIndent });
758
+ if (enabledIndexes.length > 1) {
759
+ return failLineEdit(configPath, 'config_flag_field_duplicate', `features.${flagId}.enabled appears ${enabledIndexes.length} times`);
760
+ }
761
+ const [existingEnabled] = enabledIndexes;
762
+ let enabledIndex;
763
+ if (existingEnabled !== undefined) {
764
+ const enabledLine = working[existingEnabled];
765
+ const rewritten = typeof enabledLine === 'string' ? rewriteScalarLine(enabledLine, 'enabled', String(enabled)) : null;
766
+ if (rewritten === null) {
767
+ return failLineEdit(configPath, 'config_flag_field_unsupported', `features.${flagId}.enabled is not a scalar boolean`);
768
+ }
769
+ working[existingEnabled] = rewritten;
770
+ enabledIndex = existingEnabled;
771
+ }
772
+ else {
773
+ enabledIndex = keyIndex + 1;
774
+ working.splice(enabledIndex, 0, `${' '.repeat(childIndent)}enabled: ${enabled}`);
775
+ }
776
+ const blockEndAfterInsert = flagBlockEnd(working, keyIndex, keyIndent);
777
+ const categoryIndexes = findFieldIndexes(working, { start: keyIndex + 1, end: blockEndAfterInsert }, { field: 'category', indent: childIndent });
778
+ if (categoryIndexes.length > 1) {
779
+ return failLineEdit(configPath, 'config_flag_field_duplicate', `features.${flagId}.category appears ${categoryIndexes.length} times`);
780
+ }
781
+ const [existingCategory] = categoryIndexes;
782
+ if (existingCategory !== undefined) {
783
+ const categoryLine = working[existingCategory];
784
+ const rewritten = typeof categoryLine === 'string' ? rewriteScalarLine(categoryLine, 'category', category) : null;
785
+ if (rewritten === null) {
786
+ return failLineEdit(configPath, 'config_flag_field_unsupported', `features.${flagId}.category is not a scalar string`);
787
+ }
788
+ working[existingCategory] = rewritten;
789
+ }
790
+ else {
791
+ working.splice(enabledIndex + 1, 0, `${' '.repeat(childIndent)}category: ${category}`);
792
+ }
793
+ allowedStart = keyIndex;
794
+ allowedEnd = blockEnd;
795
+ }
796
+ else if (header === null) {
797
+ working = [
798
+ ...lines,
799
+ 'features:',
800
+ ` ${flagId}:`,
801
+ ` enabled: ${enabled}`,
802
+ ` category: ${category}`,
803
+ ];
804
+ allowedStart = lines.length;
805
+ allowedEnd = lines.length;
806
+ }
807
+ else {
808
+ working = [...lines];
809
+ let keyIndent = 2;
810
+ const firstChild = working.slice(header.index + 1, featuresEnd).find((line) => {
811
+ if (typeof line !== 'string')
812
+ return false;
813
+ const body = line.trim();
814
+ return body !== '' && !body.startsWith('#');
815
+ });
816
+ if (typeof firstChild === 'string')
817
+ keyIndent = leadingSpaces(firstChild);
818
+ const childIndent = inferChildIndent(working, { start: header.index + 1, end: featuresEnd }, keyIndent) ?? keyIndent + 2;
819
+ const insertAt = header.index + 1;
820
+ if (header.style === 'empty')
821
+ working[header.index] = 'features:';
822
+ working.splice(insertAt, 0, `${' '.repeat(keyIndent)}${flagId}:`, `${' '.repeat(childIndent)}enabled: ${enabled}`, `${' '.repeat(childIndent)}category: ${category}`);
823
+ allowedStart = header.style === 'empty' ? header.index : insertAt;
824
+ allowedEnd = header.style === 'empty' ? header.index + 1 : insertAt;
825
+ }
826
+ const next = working.join(eol);
827
+ const prefix = lines.slice(0, allowedStart).join(eol);
828
+ const suffix = lines.slice(allowedEnd).join(eol);
829
+ if (!next.startsWith(prefix) || !next.endsWith(suffix)) {
830
+ return failLineEdit(configPath, 'config_edit_out_of_scope', `bytes outside features.${flagId} would change`);
831
+ }
832
+ let parsed;
833
+ try {
834
+ parsed = yaml.load(next);
835
+ }
836
+ catch {
837
+ return failLineEdit(configPath, 'config_rewrite_invalid', `the edited YAML no longer parses`);
838
+ }
839
+ const features = isRecord(parsed) ? parsed.features : undefined;
840
+ const entry = isRecord(features) ? features[flagId] : undefined;
841
+ if (!isRecord(entry) || entry.enabled !== enabled || entry.category !== category) {
842
+ return failLineEdit(configPath, 'config_rewrite_verification_failed', `features.${flagId} did not round-trip to enabled=${String(enabled)}, category=${category}`);
843
+ }
844
+ return { ok: true, next };
538
845
  }
539
846
  /**
540
847
  * Enable/disable `features.host.workbuddy.enabled` in the GOVERNANCE
@@ -551,75 +858,125 @@ function isEmptyFeaturesMapping(line) {
551
858
  * skipped with host.workbuddy_disabled.
552
859
  * Both entries are validated and atomically committed in one file write;
553
860
  * an already-correct config is left byte-identical (no rewrite).
861
+ *
862
+ * 2026-11-05 (Owner decision): `workbuddy_live_keyword_pain` is written
863
+ * `enabled: false` unconditionally. WorkBuddy has no persisted observation
864
+ * writer, so the automatic keyword path can only end as `observation_only`
865
+ * (an abandoned marker, never a Pain row). Leaving the flag ON made install
866
+ * output claim an automatic capability that does not exist. The flag id stays
867
+ * (it is part of the feature-flag contract and may exist in old configs), but
868
+ * the installer never turns it on.
554
869
  */
555
870
  export function ensureWorkbuddyGovernanceEnabled(governanceDir) {
556
871
  const configPath = path.join(governanceDir, '.pd', 'config.yaml');
872
+ let rawText;
873
+ try {
874
+ rawText = readFileSync(configPath, 'utf-8');
875
+ }
876
+ catch {
877
+ return {
878
+ ok: false,
879
+ reason: 'fresh_workbuddy_config_unreadable',
880
+ nextAction: `Fix the YAML by hand (the installer never rewrites the whole file), then retry. Affected file: ${configPath}.`,
881
+ hostExplicitOff: false,
882
+ keywordExplicitOff: false,
883
+ keywordPainEnabled: false,
884
+ changed: false,
885
+ };
886
+ }
557
887
  let raw;
558
888
  try {
559
- raw = yaml.load(readFileSync(configPath, 'utf-8'));
889
+ raw = yaml.load(rawText);
560
890
  }
561
891
  catch {
562
- return { ok: false, reason: 'fresh_workbuddy_config_unreadable', nextAction: 'Repair governance config and retry.', hostExplicitOff: false, keywordExplicitOff: false, changed: false };
892
+ return {
893
+ ok: false,
894
+ reason: 'fresh_workbuddy_config_unreadable',
895
+ nextAction: `Fix the YAML by hand (the installer never rewrites the whole file), then retry. Affected file: ${configPath}.`,
896
+ hostExplicitOff: false,
897
+ keywordExplicitOff: false,
898
+ keywordPainEnabled: false,
899
+ changed: false,
900
+ };
563
901
  }
564
902
  if (!isRecord(raw) || !isRecord(raw.features)) {
565
- return { ok: false, reason: 'fresh_workbuddy_features_invalid', nextAction: 'Governance config needs a features mapping.', hostExplicitOff: false, keywordExplicitOff: false, changed: false };
903
+ return { ok: false, reason: 'fresh_workbuddy_features_invalid', nextAction: 'Governance config needs a features mapping.', hostExplicitOff: false, keywordExplicitOff: false, keywordPainEnabled: false, changed: false };
566
904
  }
567
905
  const oldHost = raw.features['host.workbuddy'];
568
906
  const oldKeyword = raw.features.workbuddy_live_keyword_pain;
569
907
  const hostExplicitOff = isRecord(oldHost) && oldHost.enabled === false;
570
908
  const keywordExplicitOff = isRecord(oldKeyword) && oldKeyword.enabled === false;
571
- const features = {
572
- ...raw.features,
573
- 'host.workbuddy': { ...(isRecord(oldHost) ? oldHost : {}), enabled: !hostExplicitOff, category: WORKBUDDY_FLAG_CATEGORY },
574
- workbuddy_live_keyword_pain: { ...(isRecord(oldKeyword) ? oldKeyword : {}), enabled: !keywordExplicitOff, category: WORKBUDDY_FLAG_CATEGORY },
575
- };
576
- const next = yaml.dump({ ...raw, features }, { lineWidth: -1, noRefs: true });
577
909
  const alreadyCorrect = isRecord(oldHost) && oldHost.enabled === !hostExplicitOff && oldHost.category === WORKBUDDY_FLAG_CATEGORY
578
- && isRecord(oldKeyword) && oldKeyword.enabled === !keywordExplicitOff && oldKeyword.category === WORKBUDDY_FLAG_CATEGORY;
910
+ && isRecord(oldKeyword) && oldKeyword.enabled === false && oldKeyword.category === WORKBUDDY_FLAG_CATEGORY;
579
911
  if (alreadyCorrect) {
580
- return { ok: true, configPath, enabled: !hostExplicitOff, hostExplicitOff, keywordExplicitOff, changed: false };
912
+ return { ok: true, configPath, enabled: !hostExplicitOff, hostExplicitOff, keywordExplicitOff, keywordPainEnabled: false, changed: false };
581
913
  }
914
+ // Line-targeted edits: only the two flag blocks change; every comment,
915
+ // unknown feature and unrelated byte outside them is verified unchanged.
916
+ const edits = [
917
+ { flagId: 'host.workbuddy', enabled: !hostExplicitOff },
918
+ { flagId: 'workbuddy_live_keyword_pain', enabled: false },
919
+ ];
920
+ let next = rawText;
921
+ for (const edit of edits) {
922
+ const outcome = applyFeatureFlagLineEdit(next, {
923
+ flagId: edit.flagId,
924
+ enabled: edit.enabled,
925
+ category: WORKBUDDY_FLAG_CATEGORY,
926
+ configPath,
927
+ });
928
+ if (!outcome.ok) {
929
+ return { ok: false, reason: outcome.reason, nextAction: outcome.nextAction, hostExplicitOff, keywordExplicitOff, keywordPainEnabled: false, changed: false };
930
+ }
931
+ ({ next } = outcome);
932
+ }
933
+ // The summary must report the gate that the config ACTUALLY holds, not the
934
+ // value we intended to write. `applyFeatureFlagLineEdit` has already parsed
935
+ // `next` successfully, so this read only feeds the summary.
936
+ const finalParsed = yaml.load(next);
937
+ const finalFeatures = isRecord(finalParsed) ? finalParsed.features : undefined;
938
+ const finalKeyword = isRecord(finalFeatures) ? finalFeatures.workbuddy_live_keyword_pain : undefined;
939
+ const keywordPainEnabled = isRecord(finalKeyword) && finalKeyword.enabled === true;
582
940
  try {
583
- const verify = yaml.load(next);
584
- if (!isRecord(verify) || !isRecord(verify.features) ||
585
- !isRecord(verify.features['host.workbuddy']) || verify.features['host.workbuddy'].enabled !== !hostExplicitOff ||
586
- !isRecord(verify.features.workbuddy_live_keyword_pain) || verify.features.workbuddy_live_keyword_pain.enabled !== !keywordExplicitOff) {
587
- return { ok: false, reason: 'fresh_workbuddy_flags_verification_failed', nextAction: 'No changes written.', hostExplicitOff, keywordExplicitOff, changed: false };
588
- }
589
941
  atomicReplaceTextFile(configPath, next);
590
942
  }
591
943
  catch {
592
- return { ok: false, reason: 'fresh_workbuddy_flag_write_failed', nextAction: 'Repair config storage and retry.', hostExplicitOff, keywordExplicitOff, changed: false };
944
+ return { ok: false, reason: 'fresh_workbuddy_flag_write_failed', nextAction: 'Repair config storage and retry.', hostExplicitOff, keywordExplicitOff, keywordPainEnabled, changed: false };
593
945
  }
594
- return { ok: true, configPath, enabled: !hostExplicitOff, hostExplicitOff, keywordExplicitOff, changed: true };
946
+ return { ok: true, configPath, enabled: !hostExplicitOff, hostExplicitOff, keywordExplicitOff, keywordPainEnabled, changed: true };
595
947
  }
596
948
  /**
597
- * PRI-1064 review: the install summary must state BOTH gates.
598
- *
599
- * The first version derived the whole sentence from `hostExplicitOff` alone,
600
- * so a config with `workbuddy_live_keyword_pain.enabled: false` was reported as
601
- * "governance and four-term keyword Pain detection are ON" — a false summary
602
- * of exactly the kind this change set out to remove. Each gate is now stated
603
- * on its own, and an OFF state always names the flag the Owner wrote.
949
+ * PRI-1064 review: the install summary must state the governance gate
950
+ * truthfully.
604
951
  *
605
- * Inherited verbatim by the PRI-1063 copy work, which keeps the flag ids
606
- * dotted (they ARE the flat keys) but replaces the prose with paste-ready
607
- * snippets.
952
+ * 2026-11-05 (Owner decision): WorkBuddy has no persisted observation writer,
953
+ * so automatic Pain detection is UNSUPPORTED there — every keyword candidate
954
+ * ends as `observation_only` and is never persisted. The summary therefore
955
+ * NEVER reports keyword Pain as ON; it states the actual intake path (the
956
+ * explicit `PD记录痛苦:` prefix) instead. Reporting the keyword gate as ON was
957
+ * a false capability claim of exactly the kind this change set out to remove.
608
958
  */
609
959
  export function describeGovernanceDefaults(state, governanceDir) {
610
960
  if (state === null) {
611
961
  return ` Governance flag state is UNKNOWN — no governance config exists at ${governanceDir} (run "pd runtime init --workspace ${governanceDir}", then re-run the installer).`;
612
962
  }
963
+ const manualIntake = 'Automatic Pain detection is not available in WorkBuddy; record pain with the explicit prefix "PD记录痛苦:".';
613
964
  if (state.hostExplicitOff) {
614
- return ' WorkBuddy governance is OFF — your explicit opt-out (features.host.workbuddy.enabled=false) was preserved; four-term keyword Pain detection is ON.';
965
+ return ` WorkBuddy governance is OFF — your explicit opt-out (features.host.workbuddy.enabled=false) was preserved; four-term keyword Pain detection is OFF${state.keywordExplicitOff ? ' (the OFF setting was preserved)' : ' (automatic detection is unavailable in WorkBuddy, so it is kept OFF)'}. ${manualIntake}`;
615
966
  }
616
- const hostState = ` WorkBuddy governance is ON${state.changed ? ' (enabled by this install)' : ' (already enabled)'}`;
967
+ const hostState = ` WorkBuddy governance is ON${state.changed ? ' (enabled by this install)' : ' (already enabled)'} — this flag only acts through the plugin/hook channel, so it has no effect until the plugin is enabled in WorkBuddy`;
617
968
  const keywordState = state.keywordExplicitOff
618
- ? 'four-term keyword Pain detection is OFF — your explicit opt-out (features.workbuddy_live_keyword_pain.enabled=false) was preserved'
619
- : 'four-term keyword Pain detection is ON';
620
- return `${hostState}; ${keywordState}.`;
969
+ ? 'four-term keyword Pain detection is OFF — the OFF setting was preserved (an explicit Owner opt-out is never overridden)'
970
+ : 'four-term keyword Pain detection is OFF — automatic detection is unavailable in WorkBuddy, so it is kept OFF';
971
+ return `${hostState}; ${keywordState}. ${manualIntake}`;
621
972
  }
622
- export function setWorkbuddyGovernanceFlag(governanceDir, enabled) {
973
+ /**
974
+ * Generic line-targeted flag editor (flag id + registry category are
975
+ * parameters). Writes only the flag's `enabled`/`category` lines, keeps every
976
+ * other byte intact, and fails closed when the block cannot be located safely.
977
+ */
978
+ export function setWorkbuddyFeatureFlag(governanceDir, flag) {
979
+ const { flagId, enabled, category } = flag;
623
980
  const configDir = path.join(governanceDir, '.pd');
624
981
  const configPath = path.join(configDir, 'config.yaml');
625
982
  let raw;
@@ -638,125 +995,28 @@ export function setWorkbuddyGovernanceFlag(governanceDir, enabled) {
638
995
  return {
639
996
  ok: false,
640
997
  reason: `config_unparsable: ${configPath} is not valid YAML (${error instanceof Error ? error.message : String(error)}). No write was performed.`,
641
- nextAction: 'Fix or delete the malformed config.yaml, then retry the enable.',
998
+ nextAction: 'Fix or delete the malformed config.yaml by hand, then retry.',
642
999
  };
643
1000
  }
644
1001
  }
645
1002
  else {
646
1003
  raw = generateConfigYamlContent();
647
1004
  }
648
- const lines = raw.split('\n');
649
- let keyIndex = -1;
650
- for (let i = 0; i < lines.length; i++) {
651
- const line = lines[i];
652
- if (typeof line === 'string' && WORKBUDDY_KEY_LINE.test(line)) {
653
- keyIndex = i;
654
- break;
655
- }
656
- }
657
- if (keyIndex >= 0) {
658
- // Remediation (review 2026-10-13 §3 P2 + round-3 P2): locate the existing
659
- // `enabled` and `category` fields WITHIN the flag's mapping block. The
660
- // block boundary is INDENTATION, not field identity: it spans every
661
- // non-blank line indented ≥ 4 spaces after the 2-space `host.workbuddy:`
662
- // key, plus the comments/blank lines interleaved with them, and ends at
663
- // the first structural line indented < 4 spaces (dedent / next key) or
664
- // EOF. Legal entry fields other than the two we manage — e.g. `source:`
665
- // (PRI-637 provenance, FeatureFlagEntry source?: 'owner' | 'system') —
666
- // live INSIDE the block and must NEVER end the scan: the previous
667
- // first-unrecognized-line break mistook `source: owner` for the block end
668
- // and spliced duplicate keys above it, so the rewrite verification threw
669
- // (config_rewrite_invalid) and enable/disable was refused with the stale
670
- // value preserved.
671
- let enabledIndex = -1;
672
- let categoryIndex = -1;
673
- for (let i = keyIndex + 1; i < lines.length; i++) {
674
- const line = lines[i];
675
- if (typeof line !== 'string')
676
- break; // EOF — block ended
677
- if (line.trim() === '' || /^\s*#/.test(line))
678
- continue; // blank / comment — transparent, inside the block
679
- if (!/^ {4}/.test(line))
680
- break; // dedent — mapping entry ended
681
- if (WORKBUDDY_ENABLED_LINE.test(line)) {
682
- enabledIndex = i;
683
- continue;
684
- }
685
- if (WORKBUDDY_CATEGORY_LINE.test(line)) {
686
- categoryIndex = i;
687
- continue;
688
- }
689
- // Any other legal field (source: …) or nested content — inside the
690
- // block: keep scanning, never touch it.
691
- }
692
- if (enabledIndex >= 0) {
693
- // In-place flip; nothing else in the block moves.
694
- lines[enabledIndex] = ` enabled: ${enabled}`;
695
- }
696
- else {
697
- // No enabled field anywhere in the block: insert it under the key line.
698
- lines.splice(keyIndex + 1, 0, ` enabled: ${enabled}`);
699
- if (categoryIndex > keyIndex)
700
- categoryIndex += 1;
701
- }
702
- // R1 write contract: EVERY entry this function writes carries the
703
- // schema-required category (production validator requirement). Insert it
704
- // only when the block has none — never alongside an existing one.
705
- if (categoryIndex >= 0) {
706
- lines[categoryIndex] = ` category: ${WORKBUDDY_FLAG_CATEGORY}`;
707
- }
708
- else if (enabledIndex >= 0) {
709
- lines.splice(enabledIndex + 1, 0, ` category: ${WORKBUDDY_FLAG_CATEGORY}`);
710
- }
711
- else {
712
- lines.splice(keyIndex + 2, 0, ` category: ${WORKBUDDY_FLAG_CATEGORY}`);
713
- }
714
- }
715
- else {
716
- // Match BOTH an existing mapping header (`features:`) and the empty flow
717
- // mapping the installer template writes (`features: {}`) — appending a
718
- // SECOND features block instead would create a duplicate YAML key.
719
- const featuresIndex = lines.findIndex((line) => FEATURES_LINE.test(line) || FEATURES_EMPTY_MAPPING_LINE.test(line));
720
- if (featuresIndex >= 0) {
721
- const featuresLine = lines[featuresIndex];
722
- if (typeof featuresLine === 'string' && isEmptyFeaturesMapping(featuresLine)) {
723
- lines[featuresIndex] = 'features:';
724
- }
725
- lines.splice(featuresIndex + 1, 0, ` host.workbuddy:`, ` enabled: ${enabled}`, ` category: ${WORKBUDDY_FLAG_CATEGORY}`);
726
- }
727
- else {
728
- // No features: mapping at all — append a block at the end.
729
- lines.push('features:', ` host.workbuddy:`, ` enabled: ${enabled}`, ` category: ${WORKBUDDY_FLAG_CATEGORY}`);
730
- }
731
- }
732
- const next = lines.join('\n');
733
- // Round-trip verification: the rewritten config must parse with the flag at
734
- // the intended value AND the registry-required category before it is
735
- // committed (same discipline as codex-setup).
736
- let verify;
737
- try {
738
- verify = yaml.load(next);
739
- }
740
- catch (error) {
741
- return { ok: false, reason: `config_rewrite_invalid: verification parse failed (${error instanceof Error ? error.message : String(error)}). Nothing was written.`, nextAction: 'Report this failure — the flag edit produced invalid YAML.' };
742
- }
743
- const flagOk = isRecord(verify)
744
- && isRecord(verify.features)
745
- && isRecord(verify.features['host.workbuddy'])
746
- && verify.features['host.workbuddy'].enabled === enabled
747
- && verify.features['host.workbuddy'].category === WORKBUDDY_FLAG_CATEGORY;
748
- if (!flagOk) {
749
- return { ok: false, reason: 'config_rewrite_mismatch: round-trip verification did not observe the intended flag value + category. Nothing was written.', nextAction: 'Report this failure with the config.yaml content.' };
750
- }
1005
+ const outcome = applyFeatureFlagLineEdit(raw, { flagId, enabled, category, configPath });
1006
+ if (!outcome.ok)
1007
+ return outcome;
751
1008
  try {
752
1009
  mkdirSync(configDir, { recursive: true });
753
- atomicReplaceTextFile(configPath, next);
1010
+ atomicReplaceTextFile(configPath, outcome.next);
754
1011
  }
755
1012
  catch (error) {
756
1013
  return { ok: false, reason: `config_write_failed: ${configPath} (${error instanceof Error ? error.message : String(error)})`, nextAction: 'Fix permissions/free the file and retry.' };
757
1014
  }
758
1015
  return { ok: true, configPath, enabled };
759
1016
  }
1017
+ export function setWorkbuddyGovernanceFlag(governanceDir, enabled) {
1018
+ return setWorkbuddyFeatureFlag(governanceDir, { flagId: 'host.workbuddy', enabled, category: WORKBUDDY_FLAG_CATEGORY });
1019
+ }
760
1020
  /** Read the explicit host.workbuddy override state (null = no explicit override → registry default OFF). */
761
1021
  export function readWorkbuddyGovernanceFlagOverride(governanceDir) {
762
1022
  const configPath = path.join(governanceDir, '.pd', 'config.yaml');
@@ -1345,7 +1605,7 @@ export class WorkBuddyHostInstaller {
1345
1605
  // unaffected when the MCP component is missing or the write fails.
1346
1606
  const mcpBinding = writeWorkbuddyMcpBinding(p.marketplaceRoot, this.deps.homeDir());
1347
1607
  // 5. Register: engine commands first; observable direct-write fallback.
1348
- const registration = this.registerPlugin(details, payload.version);
1608
+ const registration = this.registerPlugin(details, payload.version, payload.defaultEnabled);
1349
1609
  if (!registration.ok) {
1350
1610
  return {
1351
1611
  success: false,
@@ -1441,12 +1701,15 @@ export class WorkBuddyHostInstaller {
1441
1701
  hostId: this.hostId,
1442
1702
  configPath: p.settingsPath,
1443
1703
  configAction: registration.settingsAction,
1704
+ pluginState: this.readInstalledPluginState(),
1444
1705
  governance: governanceDefaults === null
1445
1706
  ? undefined
1446
1707
  : {
1447
1708
  enabled: !governanceDefaults.hostExplicitOff,
1448
1709
  explicitOptOut: governanceDefaults.hostExplicitOff,
1449
- keywordPainEnabled: !governanceDefaults.keywordExplicitOff,
1710
+ // Read back from the config after the migration (OFF by contract),
1711
+ // never inferred from the Owner opt-out flag.
1712
+ keywordPainEnabled: governanceDefaults.keywordPainEnabled,
1450
1713
  configPath: governanceDefaults.configPath,
1451
1714
  },
1452
1715
  reason: `Registered ${WORKBUDDY_PLUGIN_KEY} via ${registration.mode}${registration.mode === 'engine' ? '' : ` (engine unreachable: ${registration.engineNote})`}. Governance workspace initialized at ${p.governanceDir}${governance.dbInit === 'skipped' ? ` (${governance.note})` : ''}.${profileNote}${governanceDefaultsNote}${mcpBinding.ok ? ' pd-recall MCP binding written.' : ` pd-recall MCP binding skipped (${mcpBinding.reason ?? 'unknown'}).`}`,
@@ -1454,7 +1717,7 @@ export class WorkBuddyHostInstaller {
1454
1717
  // main; the "Existing workspaces retain their prior settings" sentence is
1455
1718
  // deliberately dropped — PRI-1064 turns governance ON for those
1456
1719
  // workspaces, so keeping it would state the opposite of what happens.
1457
- nextAction: `${effectiveNote}${governance.dbInit === 'skipped' ? ` After the install, run "pd runtime init --workspace ${p.governanceDir}" to create the governance databases.` : ''} To disable, edit ${path.join(p.governanceDir, '.pd', 'config.yaml')} — each flag id is ONE flat key (not nested). In the existing "features:" block set workbuddy_live_keyword_pain: {enabled: false} to disable keyword Pain only, or host.workbuddy: {enabled: false} to disable all WorkBuddy governance.${pendingRebindNote}`,
1720
+ nextAction: `${effectiveNote}${governance.dbInit === 'skipped' ? ` After the install, run "pd runtime init --workspace ${p.governanceDir}" to create the governance databases.` : ''} To disable, edit ${path.join(p.governanceDir, '.pd', 'config.yaml')} — each flag id is ONE flat key (not nested). In the existing "features:" block set host.workbuddy: {enabled: false} to disable all WorkBuddy governance. Automatic Pain detection is unavailable in WorkBuddy; record pain only through the explicit "PD记录痛苦:" prefix.${pendingRebindNote}`,
1458
1721
  };
1459
1722
  }
1460
1723
  /**
@@ -1531,20 +1794,32 @@ export class WorkBuddyHostInstaller {
1531
1794
  note: `governance database initialization SKIPPED: pd-cli runtime entry not found — run "pd runtime init --workspace ${governanceDir}" after the install; until then pain evidence degrades with an explicit trajectory_db_not_found note.`,
1532
1795
  };
1533
1796
  }
1534
- registerPlugin(details, version) {
1797
+ registerPlugin(details, version, declaredDefaultEnabled) {
1535
1798
  const p = this.paths();
1536
1799
  // A registered old plugin can still be hook-only, even at the same
1537
1800
  // manifest version. Reuse the installer-owned registration/cache writer
1538
1801
  // rather than reporting a stale cache as an idempotent successful install.
1539
1802
  if (details.registered && !this.pluginCacheMatchesPayload(version)) {
1540
- return this.directWriteRegistration(details, version, 'registered plugin cache differs from the current bundled payload; refreshed by installer');
1541
- }
1542
- // Idempotent fast path: already registered AND enabled AND cached.
1543
- if (details.registered && details.pluginEnabled && existsSync(p.cachePluginRoot)) {
1803
+ return this.directWriteRegistration(details, version, {
1804
+ declaredDefaultEnabled,
1805
+ engineNote: 'registered plugin cache differs from the current bundled payload; refreshed by installer',
1806
+ });
1807
+ }
1808
+ // Idempotent fast path: already registered AND cached. The installed
1809
+ // enabled state is the OWNER's choice — install/upgrade/reinstall must not
1810
+ // flip it in either direction (install ≠ enable, SPEC §6.4).
1811
+ if (details.registered && existsSync(p.cachePluginRoot)) {
1544
1812
  return { ok: true, mode: 'already-registered', settingsAction: 'preserved' };
1545
1813
  }
1546
1814
  // Engine path (preferred — real marketplace/cache/settings chain).
1547
1815
  if (details.engineFound) {
1816
+ // Resolve the installed enabled value BEFORE the engine runs: an engine
1817
+ // install must not enable the plugin, and a pre-existing Owner choice
1818
+ // (true or false) must survive the install.
1819
+ const installedEnabled = this.resolveInstalledEnabledValue(declaredDefaultEnabled);
1820
+ if (!installedEnabled.ok) {
1821
+ return { ok: false, reason: installedEnabled.reason, nextAction: installedEnabled.nextAction };
1822
+ }
1548
1823
  const add = this.runEngine(['plugin', 'marketplace', 'add', p.marketplaceRoot]);
1549
1824
  if (add !== undefined && add.status === 0 && MARKETPLACE_ADD_OK.test(add.stdout + add.stderr)) {
1550
1825
  if (details.registered) {
@@ -1554,28 +1829,26 @@ export class WorkBuddyHostInstaller {
1554
1829
  }
1555
1830
  const install = this.runEngine(['plugin', 'install', WORKBUDDY_PLUGIN_KEY]);
1556
1831
  if (install !== undefined && install.status === 0 && PLUGIN_INSTALL_OK.test(install.stdout + install.stderr)) {
1557
- let after = this.detectDetails();
1558
- if (after.registered && !after.pluginEnabled) {
1559
- // Engine-version reality (observed on 2.161.0, 2026-10-13): the
1560
- // engine honors the payload manifest's `defaultEnabled` and
1561
- // installs hooks-carrying plugins with enabledPlugins[key]=false.
1562
- // The installer contract (P01 frozen facts + this installer's own
1563
- // direct-write path) is installed ⇒ enabledPlugins[key]=true —
1564
- // the explicit host-level act of installing. PD governance stays
1565
- // OFF either way (OD-03: features.host.workbuddy is a separate,
1566
- // later, user-visible enable).
1567
- const enabled = this.enableRegisteredPlugin();
1568
- if (!enabled.ok) {
1569
- return { ok: false, reason: enabled.reason, nextAction: enabled.nextAction };
1832
+ const after = this.detectDetails();
1833
+ if (after.registered) {
1834
+ // Converge the settings entry onto the resolved value. This covers
1835
+ // engines that would force-enable on install while still preserving
1836
+ // an Owner-chosen true/false from before the install.
1837
+ const applied = this.applyInstalledEnabledValue(installedEnabled.value);
1838
+ if (!applied.ok) {
1839
+ return { ok: false, reason: applied.reason, nextAction: applied.nextAction };
1570
1840
  }
1571
- after = this.detectDetails();
1572
- }
1573
- if (after.registered && after.pluginEnabled) {
1574
- return { ok: true, mode: 'engine', settingsAction: after.settingsExisted ? 'updated' : 'created' };
1841
+ return {
1842
+ ok: true,
1843
+ mode: 'engine',
1844
+ settingsAction: details.settingsExisted
1845
+ ? (installedEnabled.fromOwnerChoice ? 'preserved' : 'updated')
1846
+ : 'created',
1847
+ };
1575
1848
  }
1576
1849
  return {
1577
1850
  ok: false,
1578
- reason: `workbuddy_engine_install_unverified: engine reported success but the frozen-format state (enabledPlugins + installed_plugins.json) does not carry ${WORKBUDDY_PLUGIN_KEY} afterwards.`,
1851
+ reason: `workbuddy_engine_install_unverified: engine reported success but installed_plugins.json does not carry ${WORKBUDDY_PLUGIN_KEY} afterwards.`,
1579
1852
  nextAction: `Run "node <engine> plugin list" to inspect the host state, then re-run the installer.`,
1580
1853
  };
1581
1854
  }
@@ -1590,14 +1863,17 @@ export class WorkBuddyHostInstaller {
1590
1863
  const note = add !== undefined
1591
1864
  ? `engine marketplace add failed: ${((add.stderr || add.stdout).trim().slice(0, 200) || `exit ${add.status}`)}`
1592
1865
  : 'engine invocation failed';
1593
- const direct = this.directWriteRegistration(details, version, note);
1866
+ const direct = this.directWriteRegistration(details, version, { declaredDefaultEnabled, engineNote: note });
1594
1867
  if (direct.ok)
1595
1868
  return direct;
1596
1869
  return { ok: false, reason: direct.reason, nextAction: direct.nextAction };
1597
1870
  }
1598
1871
  // Engine unreachable: direct-write path, strictly aligned with the frozen
1599
1872
  // formats, and always reported as a distinct mode.
1600
- const direct = this.directWriteRegistration(details, version, 'engine binary not found');
1873
+ const direct = this.directWriteRegistration(details, version, {
1874
+ declaredDefaultEnabled,
1875
+ engineNote: 'engine binary not found',
1876
+ });
1601
1877
  if (direct.ok)
1602
1878
  return direct;
1603
1879
  return { ok: false, reason: direct.reason, nextAction: direct.nextAction };
@@ -1626,33 +1902,75 @@ export class WorkBuddyHostInstaller {
1626
1902
  }
1627
1903
  }
1628
1904
  /**
1629
- * Set enabledPlugins[OUR_KEY]=true in settings.json, preserving every other
1630
- * key (same protected write discipline as the direct-write registration).
1631
- * Converges the engine install path (which honors the payload manifest's
1632
- * defaultEnabled) onto the installer's frozen installed ⇒ enabled contract.
1905
+ * The value an install should leave behind: an explicit boolean already in
1906
+ * settings.json is the Owner's choice and always wins; otherwise the plugin
1907
+ * manifest's `defaultEnabled` applies. Install never enables by itself.
1633
1908
  */
1634
- enableRegisteredPlugin() {
1909
+ resolveInstalledEnabledValue(declaredDefaultEnabled) {
1910
+ const p = this.paths();
1911
+ const settings = readHostJsonFile(p.settingsPath);
1912
+ if (!settings.ok)
1913
+ return { ok: false, reason: settings.reason, nextAction: settings.nextAction };
1914
+ const current = isRecord(settings.value?.enabledPlugins)
1915
+ ? settings.value.enabledPlugins[WORKBUDDY_PLUGIN_KEY]
1916
+ : undefined;
1917
+ if (typeof current === 'boolean')
1918
+ return { ok: true, value: current, fromOwnerChoice: true };
1919
+ return { ok: true, value: declaredDefaultEnabled, fromOwnerChoice: false };
1920
+ }
1921
+ /** Write enabledPlugins[OUR_KEY]=value, preserving every other key. */
1922
+ applyInstalledEnabledValue(value) {
1635
1923
  const p = this.paths();
1636
1924
  const settings = readHostJsonFile(p.settingsPath);
1637
1925
  if (!settings.ok)
1638
1926
  return { ok: false, reason: settings.reason, nextAction: settings.nextAction };
1927
+ const current = isRecord(settings.value?.enabledPlugins)
1928
+ ? settings.value.enabledPlugins[WORKBUDDY_PLUGIN_KEY]
1929
+ : undefined;
1930
+ if (typeof current === 'boolean' && current === value)
1931
+ return { ok: true, changed: false };
1639
1932
  const nextSettings = { ...(settings.value ?? {}) };
1640
1933
  const enabledPlugins = isRecord(nextSettings.enabledPlugins) ? { ...nextSettings.enabledPlugins } : {};
1641
- enabledPlugins[WORKBUDDY_PLUGIN_KEY] = true;
1934
+ enabledPlugins[WORKBUDDY_PLUGIN_KEY] = value;
1642
1935
  nextSettings.enabledPlugins = enabledPlugins;
1643
1936
  const write = writeHostJsonFileProtected(p.settingsPath, JSON.stringify(nextSettings, null, 2) + '\n');
1644
1937
  if (!write.ok)
1645
1938
  return { ok: false, reason: write.reason, nextAction: write.nextAction };
1646
- return { ok: true };
1939
+ return { ok: true, changed: true };
1940
+ }
1941
+ /**
1942
+ * Read the two INDEPENDENT post-install facts straight from the host:
1943
+ * - registration → installed_plugins.json (host registry);
1944
+ * - enablement → settings.json enabledPlugins[our key].
1945
+ * Neither may be inferred from the other, from the governance flag, or from
1946
+ * the engine's exit status. An unreadable registry must NOT be reported as
1947
+ * "installed" (rc-9).
1948
+ */
1949
+ readInstalledPluginState() {
1950
+ const p = this.paths();
1951
+ const registry = readHostJsonFile(p.registryPath);
1952
+ const registered = registry.ok && registry.existed && isRecord(registry.value.plugins)
1953
+ ? Object.hasOwn(registry.value.plugins, WORKBUDDY_PLUGIN_KEY)
1954
+ : false;
1955
+ const settings = readHostJsonFile(p.settingsPath);
1956
+ const enabledPlugins = settings.ok && isRecord(settings.value?.enabledPlugins) ? settings.value.enabledPlugins : undefined;
1957
+ const raw = enabledPlugins === undefined ? undefined : enabledPlugins[WORKBUDDY_PLUGIN_KEY];
1958
+ return {
1959
+ registered,
1960
+ registryReadable: registry.ok,
1961
+ enabled: typeof raw === 'boolean' ? raw : null,
1962
+ };
1647
1963
  }
1648
1964
  /**
1649
1965
  * Direct-write registration for engine-unreachable machines, aligned with the
1650
- * frozen P01 formats: enabledPlugins[key]=true in settings.json (other keys
1651
- * and other plugins preserved), installed_plugins.json v2 user registration
1966
+ * frozen P01 formats: enabledPlugins[key] carries the manifest default or the
1967
+ * Owner's existing choice (install never enables by itself; other keys and
1968
+ * other plugins preserved), installed_plugins.json v2 user registration
1652
1969
  * (native scope array or legacy object), and the versioned cache
1653
1970
  * copy under plugins/cache/<marketplace>/<plugin>/<version>/.
1654
1971
  */
1655
- directWriteRegistration(details, version, engineNote) {
1972
+ directWriteRegistration(details, version, options) {
1973
+ const { declaredDefaultEnabled, engineNote } = options;
1656
1974
  const p = this.paths();
1657
1975
  const cacheVersionDir = workbuddyCacheVersionDir(p.cachePluginRoot, version);
1658
1976
  if (cacheVersionDir === undefined) {
@@ -1679,9 +1997,15 @@ export class WorkBuddyHostInstaller {
1679
1997
  }
1680
1998
  // settings.json merge — computed here, WRITTEN after the cache lands (see
1681
1999
  // below): a failed payload must leave no registration state behind.
2000
+ // Install ≠ enable (SPEC §6.4): an explicit Owner choice is preserved;
2001
+ // otherwise the manifest's defaultEnabled applies.
2002
+ const currentEnabled = isRecord(settings.value?.enabledPlugins)
2003
+ ? settings.value.enabledPlugins[WORKBUDDY_PLUGIN_KEY]
2004
+ : undefined;
2005
+ const installedEnabled = typeof currentEnabled === 'boolean' ? currentEnabled : declaredDefaultEnabled;
1682
2006
  const nextSettings = { ...(settings.value ?? {}) };
1683
2007
  const enabledPlugins = isRecord(nextSettings.enabledPlugins) ? { ...nextSettings.enabledPlugins } : {};
1684
- enabledPlugins[WORKBUDDY_PLUGIN_KEY] = true;
2008
+ enabledPlugins[WORKBUDDY_PLUGIN_KEY] = installedEnabled;
1685
2009
  nextSettings.enabledPlugins = enabledPlugins;
1686
2010
  const settingsAction = settings.existed ? 'updated' : 'created';
1687
2011
  // installed_plugins.json v2 merge (create with version 2 when absent)