create-principles-disciple 1.147.7 → 1.147.9

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 (123) hide show
  1. package/_release/product-identity.json +2 -2
  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/activation/adaptive-delivery-record.d.ts +37 -0
  6. package/core/dist/runtime-v2/activation/adaptive-delivery-record.d.ts.map +1 -0
  7. package/core/dist/runtime-v2/activation/adaptive-delivery-record.js +126 -0
  8. package/core/dist/runtime-v2/activation/adaptive-delivery-record.js.map +1 -0
  9. package/core/dist/runtime-v2/activation/adaptive-principle-intervention.d.ts +46 -0
  10. package/core/dist/runtime-v2/activation/adaptive-principle-intervention.d.ts.map +1 -0
  11. package/core/dist/runtime-v2/activation/adaptive-principle-intervention.js +91 -0
  12. package/core/dist/runtime-v2/activation/adaptive-principle-intervention.js.map +1 -0
  13. package/core/dist/runtime-v2/activation/prompt-activation-reader-contract.d.ts +2 -0
  14. package/core/dist/runtime-v2/activation/prompt-activation-reader-contract.d.ts.map +1 -1
  15. package/core/dist/runtime-v2/activation/prompt-activation-reader-contract.js +2 -0
  16. package/core/dist/runtime-v2/activation/prompt-activation-reader-contract.js.map +1 -1
  17. package/core/dist/runtime-v2/feature-flags/feature-flag-contract.js +1 -1
  18. package/core/dist/runtime-v2/feature-flags/feature-flag-contract.js.map +1 -1
  19. package/core/dist/runtime-v2/feature-flags/feature-flag-lifecycle.js +2 -2
  20. package/core/dist/runtime-v2/feature-flags/feature-flag-lifecycle.js.map +1 -1
  21. package/core/dist/runtime-v2/index.d.ts +3 -0
  22. package/core/dist/runtime-v2/index.d.ts.map +1 -1
  23. package/core/dist/runtime-v2/index.js +2 -0
  24. package/core/dist/runtime-v2/index.js.map +1 -1
  25. package/core/dist/runtime-v2/pain-recall-capability.d.ts.map +1 -1
  26. package/core/dist/runtime-v2/pain-recall-capability.js +53 -2
  27. package/core/dist/runtime-v2/pain-recall-capability.js.map +1 -1
  28. package/core/dist/runtime-v2/pain-recall-read-model.d.ts.map +1 -1
  29. package/core/dist/runtime-v2/pain-recall-read-model.js +60 -5
  30. package/core/dist/runtime-v2/pain-recall-read-model.js.map +1 -1
  31. package/core/dist/runtime-v2/signal-collector/keyword-stage.d.ts.map +1 -1
  32. package/core/dist/runtime-v2/signal-collector/keyword-stage.js +35 -0
  33. package/core/dist/runtime-v2/signal-collector/keyword-stage.js.map +1 -1
  34. package/core/dist/runtime-v2/store/sqlite-connection.js +1 -1
  35. package/core/dist/runtime-v2/store/sqlite-connection.js.map +1 -1
  36. package/core/dist/runtime-v2/types/event-types.d.ts +6 -3
  37. package/core/dist/runtime-v2/types/event-types.d.ts.map +1 -1
  38. package/core/dist/runtime-v2/types/event-types.js +2 -0
  39. package/core/dist/runtime-v2/types/event-types.js.map +1 -1
  40. package/core/package.json +5 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +15 -29
  43. package/dist/index.js.map +1 -1
  44. package/dist/install-summary.d.ts +41 -0
  45. package/dist/install-summary.d.ts.map +1 -0
  46. package/dist/install-summary.js +123 -0
  47. package/dist/install-summary.js.map +1 -0
  48. package/dist/installer.d.ts +36 -0
  49. package/dist/installer.d.ts.map +1 -1
  50. package/dist/installer.js +120 -26
  51. package/dist/installer.js.map +1 -1
  52. package/dist/installers/workbuddy-host-installer.d.ts +70 -17
  53. package/dist/installers/workbuddy-host-installer.d.ts.map +1 -1
  54. package/dist/installers/workbuddy-host-installer.js +571 -186
  55. package/dist/installers/workbuddy-host-installer.js.map +1 -1
  56. package/dist/prompts.js +1 -1
  57. package/dist/prompts.js.map +1 -1
  58. package/host-runtime/dist/active-principle-prompt.d.ts +28 -0
  59. package/host-runtime/dist/active-principle-prompt.js +163 -50
  60. package/host-runtime/dist/adaptive-principle-revalidation.d.ts +8 -0
  61. package/host-runtime/dist/adaptive-principle-revalidation.js +52 -0
  62. package/host-runtime/dist/governance-signal-admission.d.ts +2 -2
  63. package/host-runtime/dist/governance-signal-admission.js +2 -6
  64. package/host-runtime/dist/index.d.ts +16 -0
  65. package/host-runtime/dist/index.js +34 -0
  66. package/host-runtime/dist/prompt-activation-authority.d.ts +7 -0
  67. package/host-runtime/dist/prompt-activation-authority.js +16 -0
  68. package/host-runtime/package.json +1 -1
  69. package/package.json +3 -3
  70. package/pd-cli/dist/commands/health-mcp.d.ts +19 -0
  71. package/pd-cli/dist/commands/health-mcp.d.ts.map +1 -1
  72. package/pd-cli/dist/commands/health-mcp.js +226 -0
  73. package/pd-cli/dist/commands/health-mcp.js.map +1 -1
  74. package/pd-cli/dist/commands/health.d.ts.map +1 -1
  75. package/pd-cli/dist/commands/health.js +18 -1
  76. package/pd-cli/dist/commands/health.js.map +1 -1
  77. package/pd-cli/dist/commands/mcp-logs-export.d.ts +7 -0
  78. package/pd-cli/dist/commands/mcp-logs-export.d.ts.map +1 -0
  79. package/pd-cli/dist/commands/mcp-logs-export.js +267 -0
  80. package/pd-cli/dist/commands/mcp-logs-export.js.map +1 -0
  81. package/pd-cli/dist/commands/workbuddy-worker.d.ts.map +1 -1
  82. package/pd-cli/dist/commands/workbuddy-worker.js +2 -1
  83. package/pd-cli/dist/commands/workbuddy-worker.js.map +1 -1
  84. package/pd-cli/dist/index.js +35 -0
  85. package/pd-cli/dist/index.js.map +1 -1
  86. package/pd-cli/dist/services/zip-writer.d.ts +6 -0
  87. package/pd-cli/dist/services/zip-writer.d.ts.map +1 -0
  88. package/pd-cli/dist/services/zip-writer.js +97 -0
  89. package/pd-cli/dist/services/zip-writer.js.map +1 -0
  90. package/pd-cli/package.json +1 -1
  91. package/pd-mcp-server/dist/index.d.ts +1 -0
  92. package/pd-mcp-server/dist/index.js +167 -10
  93. package/pd-mcp-server/dist/log-file.d.ts +26 -0
  94. package/pd-mcp-server/dist/log-file.js +312 -0
  95. package/pd-mcp-server/dist/logging.d.ts +2 -1
  96. package/pd-mcp-server/dist/logging.js +25 -14
  97. package/pd-mcp-server/dist/server.js +78 -24
  98. package/pd-mcp-server/dist/version.d.ts +1 -1
  99. package/pd-mcp-server/dist/version.js +1 -1
  100. package/pd-mcp-server/dist/workspace.d.ts +3 -0
  101. package/pd-mcp-server/dist/workspace.js +5 -0
  102. package/pd-mcp-server/package.json +1 -1
  103. package/plugin/dist/bundle.js +446 -445
  104. package/plugin/dist/core/event-log.d.ts +3 -0
  105. package/plugin/dist/governance-audit.js +3 -3
  106. package/plugin/dist/hooks/adaptive-activation-config.d.ts +8 -0
  107. package/plugin/dist/hooks/prompt-helpers.d.ts +5 -0
  108. package/plugin/dist/hooks/prompt-types.d.ts +1 -0
  109. package/plugin/dist/hooks/prompt.d.ts +8 -1
  110. package/plugin/dist/host-runtime/openclaw-host-runtime.d.ts +13 -0
  111. package/plugin/dist/openclaw-sdk.d.ts +4 -0
  112. package/plugin/dist/openclaw.plugin.json +33 -1
  113. package/plugin/mcp/launcher.cjs +561 -0
  114. package/plugin/openclaw.plugin.json +33 -1
  115. package/plugin/package.json +1 -1
  116. package/release-manager/dist/installer.js +120 -26
  117. package/release-manager/dist/installers/workbuddy-host-installer.js +571 -186
  118. package/release-manager/package.json +1 -1
  119. package/workbuddy-adapter/dist/pd-hook.js +3 -2
  120. package/workbuddy-adapter/package.json +1 -1
  121. package/workbuddy-adapter/plugin/.codebuddy-plugin/plugin.json +1 -1
  122. package/workbuddy-adapter/plugin/mcp/launcher.cjs +215 -1
  123. 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,198 @@ 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
- export function ensureWorkbuddyGovernanceEnabled(governanceDir) {
870
+ export function ensureWorkbuddyGovernanceEnabled(governanceDir, options = {}) {
871
+ const persist = options.persist !== false;
556
872
  const configPath = path.join(governanceDir, '.pd', 'config.yaml');
873
+ let rawText;
874
+ try {
875
+ rawText = readFileSync(configPath, 'utf-8');
876
+ }
877
+ catch {
878
+ return {
879
+ ok: false,
880
+ reason: 'fresh_workbuddy_config_unreadable',
881
+ nextAction: `Fix the YAML by hand (the installer never rewrites the whole file), then retry. Affected file: ${configPath}.`,
882
+ hostExplicitOff: false,
883
+ keywordExplicitOff: false,
884
+ keywordPainEnabled: false,
885
+ changed: false,
886
+ };
887
+ }
557
888
  let raw;
558
889
  try {
559
- raw = yaml.load(readFileSync(configPath, 'utf-8'));
890
+ raw = yaml.load(rawText);
560
891
  }
561
892
  catch {
562
- return { ok: false, reason: 'fresh_workbuddy_config_unreadable', nextAction: 'Repair governance config and retry.', hostExplicitOff: false, keywordExplicitOff: false, changed: false };
893
+ return {
894
+ ok: false,
895
+ reason: 'fresh_workbuddy_config_unreadable',
896
+ nextAction: `Fix the YAML by hand (the installer never rewrites the whole file), then retry. Affected file: ${configPath}.`,
897
+ hostExplicitOff: false,
898
+ keywordExplicitOff: false,
899
+ keywordPainEnabled: false,
900
+ changed: false,
901
+ };
563
902
  }
564
903
  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 };
904
+ return { ok: false, reason: 'fresh_workbuddy_features_invalid', nextAction: 'Governance config needs a features mapping.', hostExplicitOff: false, keywordExplicitOff: false, keywordPainEnabled: false, changed: false };
566
905
  }
567
906
  const oldHost = raw.features['host.workbuddy'];
568
907
  const oldKeyword = raw.features.workbuddy_live_keyword_pain;
569
908
  const hostExplicitOff = isRecord(oldHost) && oldHost.enabled === false;
570
909
  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 });
910
+ // Read-only mode (`status`): report the gates the config ACTUALLY holds and
911
+ // never write. A flag that is absent is OFF (the registry default), never the
912
+ // install-time projection — guessing ON would misreport a config that does
913
+ // not set the gate. The install path keeps its migration below.
914
+ if (!persist) {
915
+ return {
916
+ ok: true, configPath,
917
+ enabled: isRecord(oldHost) && oldHost.enabled === true,
918
+ hostExplicitOff, keywordExplicitOff,
919
+ keywordPainEnabled: isRecord(oldKeyword) && oldKeyword.enabled === true, changed: false,
920
+ };
921
+ }
577
922
  const alreadyCorrect = isRecord(oldHost) && oldHost.enabled === !hostExplicitOff && oldHost.category === WORKBUDDY_FLAG_CATEGORY
578
- && isRecord(oldKeyword) && oldKeyword.enabled === !keywordExplicitOff && oldKeyword.category === WORKBUDDY_FLAG_CATEGORY;
923
+ && isRecord(oldKeyword) && oldKeyword.enabled === false && oldKeyword.category === WORKBUDDY_FLAG_CATEGORY;
579
924
  if (alreadyCorrect) {
580
- return { ok: true, configPath, enabled: !hostExplicitOff, hostExplicitOff, keywordExplicitOff, changed: false };
925
+ return { ok: true, configPath, enabled: !hostExplicitOff, hostExplicitOff, keywordExplicitOff, keywordPainEnabled: false, changed: false };
581
926
  }
927
+ // Line-targeted edits: only the two flag blocks change; every comment,
928
+ // unknown feature and unrelated byte outside them is verified unchanged.
929
+ const edits = [
930
+ { flagId: 'host.workbuddy', enabled: !hostExplicitOff },
931
+ { flagId: 'workbuddy_live_keyword_pain', enabled: false },
932
+ ];
933
+ let next = rawText;
934
+ for (const edit of edits) {
935
+ const outcome = applyFeatureFlagLineEdit(next, {
936
+ flagId: edit.flagId,
937
+ enabled: edit.enabled,
938
+ category: WORKBUDDY_FLAG_CATEGORY,
939
+ configPath,
940
+ });
941
+ if (!outcome.ok) {
942
+ return { ok: false, reason: outcome.reason, nextAction: outcome.nextAction, hostExplicitOff, keywordExplicitOff, keywordPainEnabled: false, changed: false };
943
+ }
944
+ ({ next } = outcome);
945
+ }
946
+ // The summary must report the gate that the config ACTUALLY holds, not the
947
+ // value we intended to write. `applyFeatureFlagLineEdit` has already parsed
948
+ // `next` successfully, so this read only feeds the summary.
949
+ const finalParsed = yaml.load(next);
950
+ const finalFeatures = isRecord(finalParsed) ? finalParsed.features : undefined;
951
+ const finalKeyword = isRecord(finalFeatures) ? finalFeatures.workbuddy_live_keyword_pain : undefined;
952
+ const keywordPainEnabled = isRecord(finalKeyword) && finalKeyword.enabled === true;
582
953
  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
954
  atomicReplaceTextFile(configPath, next);
590
955
  }
591
956
  catch {
592
- return { ok: false, reason: 'fresh_workbuddy_flag_write_failed', nextAction: 'Repair config storage and retry.', hostExplicitOff, keywordExplicitOff, changed: false };
957
+ return { ok: false, reason: 'fresh_workbuddy_flag_write_failed', nextAction: 'Repair config storage and retry.', hostExplicitOff, keywordExplicitOff, keywordPainEnabled, changed: false };
593
958
  }
594
- return { ok: true, configPath, enabled: !hostExplicitOff, hostExplicitOff, keywordExplicitOff, changed: true };
959
+ return { ok: true, configPath, enabled: !hostExplicitOff, hostExplicitOff, keywordExplicitOff, keywordPainEnabled, changed: true };
595
960
  }
596
961
  /**
597
- * PRI-1064 review: the install summary must state BOTH gates.
962
+ * The single plugin-state judgment: registration (registry) and enablement
963
+ * (settings) are INDEPENDENT facts and neither may be inferred from the other
964
+ * (OD-03). Extracted so the install path and the read-only `status` path share
965
+ * exactly one implementation.
966
+ */
967
+ export function readWorkbuddyPluginState(p) {
968
+ const registry = readHostJsonFile(p.registryPath);
969
+ const registered = registry.ok && registry.existed && isRecord(registry.value.plugins)
970
+ ? Object.hasOwn(registry.value.plugins, WORKBUDDY_PLUGIN_KEY)
971
+ : false;
972
+ const settings = readHostJsonFile(p.settingsPath);
973
+ const enabledPlugins = settings.ok && isRecord(settings.value?.enabledPlugins) ? settings.value.enabledPlugins : undefined;
974
+ const raw = enabledPlugins === undefined ? undefined : enabledPlugins[WORKBUDDY_PLUGIN_KEY];
975
+ return {
976
+ registered,
977
+ registryReadable: registry.ok,
978
+ enabled: typeof raw === 'boolean' ? raw : null,
979
+ };
980
+ }
981
+ /**
982
+ * Read-only host state projection for the installer's `status` command.
598
983
  *
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.
984
+ * It reports the SAME three independent facts the install summary reports —
985
+ * registry presence, enablement, governance gates — and is rendered by the
986
+ * SAME authority (`formatHostStateSummary`). It never writes: an unreadable
987
+ * source becomes UNKNOWN/UNREADABLE, never a guessed value.
988
+ */
989
+ export function readWorkbuddyHostState(homeDir) {
990
+ const p = resolveWorkbuddyPaths(homeDir);
991
+ const pluginState = readWorkbuddyPluginState(p);
992
+ const governance = ensureWorkbuddyGovernanceEnabled(p.governanceDir, { persist: false });
993
+ if (!governance.ok) {
994
+ const configPath = path.join(p.governanceDir, '.pd', 'config.yaml');
995
+ return {
996
+ success: true,
997
+ hostId: 'workbuddy',
998
+ pluginState,
999
+ governance: {
1000
+ enabled: false,
1001
+ explicitOptOut: false,
1002
+ configPath,
1003
+ readable: false,
1004
+ unreadableReason: `${governance.reason} (${configPath})`,
1005
+ },
1006
+ };
1007
+ }
1008
+ return {
1009
+ success: true,
1010
+ hostId: 'workbuddy',
1011
+ pluginState,
1012
+ governance: {
1013
+ enabled: governance.enabled,
1014
+ explicitOptOut: governance.hostExplicitOff,
1015
+ keywordPainEnabled: governance.keywordPainEnabled,
1016
+ configPath: governance.configPath,
1017
+ readable: true,
1018
+ },
1019
+ };
1020
+ }
1021
+ /**
1022
+ * PRI-1064 review: the install summary must state the governance gate
1023
+ * truthfully.
604
1024
  *
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.
1025
+ * 2026-11-05 (Owner decision): WorkBuddy has no persisted observation writer,
1026
+ * so automatic Pain detection is UNSUPPORTED there — every keyword candidate
1027
+ * ends as `observation_only` and is never persisted. The summary therefore
1028
+ * NEVER reports keyword Pain as ON; it states the actual intake path (the
1029
+ * explicit `PD记录痛苦:` prefix) instead. Reporting the keyword gate as ON was
1030
+ * a false capability claim of exactly the kind this change set out to remove.
608
1031
  */
609
1032
  export function describeGovernanceDefaults(state, governanceDir) {
610
1033
  if (state === null) {
611
1034
  return ` Governance flag state is UNKNOWN — no governance config exists at ${governanceDir} (run "pd runtime init --workspace ${governanceDir}", then re-run the installer).`;
612
1035
  }
1036
+ const manualIntake = 'Automatic Pain detection is not available in WorkBuddy; record pain with the explicit prefix "PD记录痛苦:".';
613
1037
  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.';
1038
+ 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
1039
  }
616
- const hostState = ` WorkBuddy governance is ON${state.changed ? ' (enabled by this install)' : ' (already enabled)'}`;
1040
+ 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
1041
  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}.`;
1042
+ ? 'four-term keyword Pain detection is OFF — the OFF setting was preserved (an explicit Owner opt-out is never overridden)'
1043
+ : 'four-term keyword Pain detection is OFF — automatic detection is unavailable in WorkBuddy, so it is kept OFF';
1044
+ return `${hostState}; ${keywordState}. ${manualIntake}`;
621
1045
  }
622
- export function setWorkbuddyGovernanceFlag(governanceDir, enabled) {
1046
+ /**
1047
+ * Generic line-targeted flag editor (flag id + registry category are
1048
+ * parameters). Writes only the flag's `enabled`/`category` lines, keeps every
1049
+ * other byte intact, and fails closed when the block cannot be located safely.
1050
+ */
1051
+ export function setWorkbuddyFeatureFlag(governanceDir, flag) {
1052
+ const { flagId, enabled, category } = flag;
623
1053
  const configDir = path.join(governanceDir, '.pd');
624
1054
  const configPath = path.join(configDir, 'config.yaml');
625
1055
  let raw;
@@ -638,125 +1068,28 @@ export function setWorkbuddyGovernanceFlag(governanceDir, enabled) {
638
1068
  return {
639
1069
  ok: false,
640
1070
  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.',
1071
+ nextAction: 'Fix or delete the malformed config.yaml by hand, then retry.',
642
1072
  };
643
1073
  }
644
1074
  }
645
1075
  else {
646
1076
  raw = generateConfigYamlContent();
647
1077
  }
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
- }
1078
+ const outcome = applyFeatureFlagLineEdit(raw, { flagId, enabled, category, configPath });
1079
+ if (!outcome.ok)
1080
+ return outcome;
751
1081
  try {
752
1082
  mkdirSync(configDir, { recursive: true });
753
- atomicReplaceTextFile(configPath, next);
1083
+ atomicReplaceTextFile(configPath, outcome.next);
754
1084
  }
755
1085
  catch (error) {
756
1086
  return { ok: false, reason: `config_write_failed: ${configPath} (${error instanceof Error ? error.message : String(error)})`, nextAction: 'Fix permissions/free the file and retry.' };
757
1087
  }
758
1088
  return { ok: true, configPath, enabled };
759
1089
  }
1090
+ export function setWorkbuddyGovernanceFlag(governanceDir, enabled) {
1091
+ return setWorkbuddyFeatureFlag(governanceDir, { flagId: 'host.workbuddy', enabled, category: WORKBUDDY_FLAG_CATEGORY });
1092
+ }
760
1093
  /** Read the explicit host.workbuddy override state (null = no explicit override → registry default OFF). */
761
1094
  export function readWorkbuddyGovernanceFlagOverride(governanceDir) {
762
1095
  const configPath = path.join(governanceDir, '.pd', 'config.yaml');
@@ -1345,7 +1678,7 @@ export class WorkBuddyHostInstaller {
1345
1678
  // unaffected when the MCP component is missing or the write fails.
1346
1679
  const mcpBinding = writeWorkbuddyMcpBinding(p.marketplaceRoot, this.deps.homeDir());
1347
1680
  // 5. Register: engine commands first; observable direct-write fallback.
1348
- const registration = this.registerPlugin(details, payload.version);
1681
+ const registration = this.registerPlugin(details, payload.version, payload.defaultEnabled);
1349
1682
  if (!registration.ok) {
1350
1683
  return {
1351
1684
  success: false,
@@ -1441,12 +1774,15 @@ export class WorkBuddyHostInstaller {
1441
1774
  hostId: this.hostId,
1442
1775
  configPath: p.settingsPath,
1443
1776
  configAction: registration.settingsAction,
1777
+ pluginState: this.readInstalledPluginState(),
1444
1778
  governance: governanceDefaults === null
1445
1779
  ? undefined
1446
1780
  : {
1447
1781
  enabled: !governanceDefaults.hostExplicitOff,
1448
1782
  explicitOptOut: governanceDefaults.hostExplicitOff,
1449
- keywordPainEnabled: !governanceDefaults.keywordExplicitOff,
1783
+ // Read back from the config after the migration (OFF by contract),
1784
+ // never inferred from the Owner opt-out flag.
1785
+ keywordPainEnabled: governanceDefaults.keywordPainEnabled,
1450
1786
  configPath: governanceDefaults.configPath,
1451
1787
  },
1452
1788
  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 +1790,7 @@ export class WorkBuddyHostInstaller {
1454
1790
  // main; the "Existing workspaces retain their prior settings" sentence is
1455
1791
  // deliberately dropped — PRI-1064 turns governance ON for those
1456
1792
  // 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}`,
1793
+ 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
1794
  };
1459
1795
  }
1460
1796
  /**
@@ -1531,20 +1867,32 @@ export class WorkBuddyHostInstaller {
1531
1867
  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
1868
  };
1533
1869
  }
1534
- registerPlugin(details, version) {
1870
+ registerPlugin(details, version, declaredDefaultEnabled) {
1535
1871
  const p = this.paths();
1536
1872
  // A registered old plugin can still be hook-only, even at the same
1537
1873
  // manifest version. Reuse the installer-owned registration/cache writer
1538
1874
  // rather than reporting a stale cache as an idempotent successful install.
1539
1875
  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)) {
1876
+ return this.directWriteRegistration(details, version, {
1877
+ declaredDefaultEnabled,
1878
+ engineNote: 'registered plugin cache differs from the current bundled payload; refreshed by installer',
1879
+ });
1880
+ }
1881
+ // Idempotent fast path: already registered AND cached. The installed
1882
+ // enabled state is the OWNER's choice — install/upgrade/reinstall must not
1883
+ // flip it in either direction (install ≠ enable, SPEC §6.4).
1884
+ if (details.registered && existsSync(p.cachePluginRoot)) {
1544
1885
  return { ok: true, mode: 'already-registered', settingsAction: 'preserved' };
1545
1886
  }
1546
1887
  // Engine path (preferred — real marketplace/cache/settings chain).
1547
1888
  if (details.engineFound) {
1889
+ // Resolve the installed enabled value BEFORE the engine runs: an engine
1890
+ // install must not enable the plugin, and a pre-existing Owner choice
1891
+ // (true or false) must survive the install.
1892
+ const installedEnabled = this.resolveInstalledEnabledValue(declaredDefaultEnabled);
1893
+ if (!installedEnabled.ok) {
1894
+ return { ok: false, reason: installedEnabled.reason, nextAction: installedEnabled.nextAction };
1895
+ }
1548
1896
  const add = this.runEngine(['plugin', 'marketplace', 'add', p.marketplaceRoot]);
1549
1897
  if (add !== undefined && add.status === 0 && MARKETPLACE_ADD_OK.test(add.stdout + add.stderr)) {
1550
1898
  if (details.registered) {
@@ -1554,28 +1902,26 @@ export class WorkBuddyHostInstaller {
1554
1902
  }
1555
1903
  const install = this.runEngine(['plugin', 'install', WORKBUDDY_PLUGIN_KEY]);
1556
1904
  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 };
1905
+ const after = this.detectDetails();
1906
+ if (after.registered) {
1907
+ // Converge the settings entry onto the resolved value. This covers
1908
+ // engines that would force-enable on install while still preserving
1909
+ // an Owner-chosen true/false from before the install.
1910
+ const applied = this.applyInstalledEnabledValue(installedEnabled.value);
1911
+ if (!applied.ok) {
1912
+ return { ok: false, reason: applied.reason, nextAction: applied.nextAction };
1570
1913
  }
1571
- after = this.detectDetails();
1572
- }
1573
- if (after.registered && after.pluginEnabled) {
1574
- return { ok: true, mode: 'engine', settingsAction: after.settingsExisted ? 'updated' : 'created' };
1914
+ return {
1915
+ ok: true,
1916
+ mode: 'engine',
1917
+ settingsAction: details.settingsExisted
1918
+ ? (installedEnabled.fromOwnerChoice ? 'preserved' : 'updated')
1919
+ : 'created',
1920
+ };
1575
1921
  }
1576
1922
  return {
1577
1923
  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.`,
1924
+ reason: `workbuddy_engine_install_unverified: engine reported success but installed_plugins.json does not carry ${WORKBUDDY_PLUGIN_KEY} afterwards.`,
1579
1925
  nextAction: `Run "node <engine> plugin list" to inspect the host state, then re-run the installer.`,
1580
1926
  };
1581
1927
  }
@@ -1590,14 +1936,17 @@ export class WorkBuddyHostInstaller {
1590
1936
  const note = add !== undefined
1591
1937
  ? `engine marketplace add failed: ${((add.stderr || add.stdout).trim().slice(0, 200) || `exit ${add.status}`)}`
1592
1938
  : 'engine invocation failed';
1593
- const direct = this.directWriteRegistration(details, version, note);
1939
+ const direct = this.directWriteRegistration(details, version, { declaredDefaultEnabled, engineNote: note });
1594
1940
  if (direct.ok)
1595
1941
  return direct;
1596
1942
  return { ok: false, reason: direct.reason, nextAction: direct.nextAction };
1597
1943
  }
1598
1944
  // Engine unreachable: direct-write path, strictly aligned with the frozen
1599
1945
  // formats, and always reported as a distinct mode.
1600
- const direct = this.directWriteRegistration(details, version, 'engine binary not found');
1946
+ const direct = this.directWriteRegistration(details, version, {
1947
+ declaredDefaultEnabled,
1948
+ engineNote: 'engine binary not found',
1949
+ });
1601
1950
  if (direct.ok)
1602
1951
  return direct;
1603
1952
  return { ok: false, reason: direct.reason, nextAction: direct.nextAction };
@@ -1626,33 +1975,63 @@ export class WorkBuddyHostInstaller {
1626
1975
  }
1627
1976
  }
1628
1977
  /**
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.
1978
+ * The value an install should leave behind: an explicit boolean already in
1979
+ * settings.json is the Owner's choice and always wins; otherwise the plugin
1980
+ * manifest's `defaultEnabled` applies. Install never enables by itself.
1633
1981
  */
1634
- enableRegisteredPlugin() {
1982
+ resolveInstalledEnabledValue(declaredDefaultEnabled) {
1635
1983
  const p = this.paths();
1636
1984
  const settings = readHostJsonFile(p.settingsPath);
1637
1985
  if (!settings.ok)
1638
1986
  return { ok: false, reason: settings.reason, nextAction: settings.nextAction };
1987
+ const current = isRecord(settings.value?.enabledPlugins)
1988
+ ? settings.value.enabledPlugins[WORKBUDDY_PLUGIN_KEY]
1989
+ : undefined;
1990
+ if (typeof current === 'boolean')
1991
+ return { ok: true, value: current, fromOwnerChoice: true };
1992
+ return { ok: true, value: declaredDefaultEnabled, fromOwnerChoice: false };
1993
+ }
1994
+ /** Write enabledPlugins[OUR_KEY]=value, preserving every other key. */
1995
+ applyInstalledEnabledValue(value) {
1996
+ const p = this.paths();
1997
+ const settings = readHostJsonFile(p.settingsPath);
1998
+ if (!settings.ok)
1999
+ return { ok: false, reason: settings.reason, nextAction: settings.nextAction };
2000
+ const current = isRecord(settings.value?.enabledPlugins)
2001
+ ? settings.value.enabledPlugins[WORKBUDDY_PLUGIN_KEY]
2002
+ : undefined;
2003
+ if (typeof current === 'boolean' && current === value)
2004
+ return { ok: true, changed: false };
1639
2005
  const nextSettings = { ...(settings.value ?? {}) };
1640
2006
  const enabledPlugins = isRecord(nextSettings.enabledPlugins) ? { ...nextSettings.enabledPlugins } : {};
1641
- enabledPlugins[WORKBUDDY_PLUGIN_KEY] = true;
2007
+ enabledPlugins[WORKBUDDY_PLUGIN_KEY] = value;
1642
2008
  nextSettings.enabledPlugins = enabledPlugins;
1643
2009
  const write = writeHostJsonFileProtected(p.settingsPath, JSON.stringify(nextSettings, null, 2) + '\n');
1644
2010
  if (!write.ok)
1645
2011
  return { ok: false, reason: write.reason, nextAction: write.nextAction };
1646
- return { ok: true };
2012
+ return { ok: true, changed: true };
2013
+ }
2014
+ /**
2015
+ * Read the two INDEPENDENT post-install facts straight from the host:
2016
+ * - registration → installed_plugins.json (host registry);
2017
+ * - enablement → settings.json enabledPlugins[our key].
2018
+ * Neither may be inferred from the other, from the governance flag, or from
2019
+ * the engine's exit status. An unreadable registry must NOT be reported as
2020
+ * "installed" (rc-9).
2021
+ */
2022
+ readInstalledPluginState() {
2023
+ return readWorkbuddyPluginState(this.paths());
1647
2024
  }
1648
2025
  /**
1649
2026
  * 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
2027
+ * frozen P01 formats: enabledPlugins[key] carries the manifest default or the
2028
+ * Owner's existing choice (install never enables by itself; other keys and
2029
+ * other plugins preserved), installed_plugins.json v2 user registration
1652
2030
  * (native scope array or legacy object), and the versioned cache
1653
2031
  * copy under plugins/cache/<marketplace>/<plugin>/<version>/.
1654
2032
  */
1655
- directWriteRegistration(details, version, engineNote) {
2033
+ directWriteRegistration(details, version, options) {
2034
+ const { declaredDefaultEnabled, engineNote } = options;
1656
2035
  const p = this.paths();
1657
2036
  const cacheVersionDir = workbuddyCacheVersionDir(p.cachePluginRoot, version);
1658
2037
  if (cacheVersionDir === undefined) {
@@ -1679,9 +2058,15 @@ export class WorkBuddyHostInstaller {
1679
2058
  }
1680
2059
  // settings.json merge — computed here, WRITTEN after the cache lands (see
1681
2060
  // below): a failed payload must leave no registration state behind.
2061
+ // Install ≠ enable (SPEC §6.4): an explicit Owner choice is preserved;
2062
+ // otherwise the manifest's defaultEnabled applies.
2063
+ const currentEnabled = isRecord(settings.value?.enabledPlugins)
2064
+ ? settings.value.enabledPlugins[WORKBUDDY_PLUGIN_KEY]
2065
+ : undefined;
2066
+ const installedEnabled = typeof currentEnabled === 'boolean' ? currentEnabled : declaredDefaultEnabled;
1682
2067
  const nextSettings = { ...(settings.value ?? {}) };
1683
2068
  const enabledPlugins = isRecord(nextSettings.enabledPlugins) ? { ...nextSettings.enabledPlugins } : {};
1684
- enabledPlugins[WORKBUDDY_PLUGIN_KEY] = true;
2069
+ enabledPlugins[WORKBUDDY_PLUGIN_KEY] = installedEnabled;
1685
2070
  nextSettings.enabledPlugins = enabledPlugins;
1686
2071
  const settingsAction = settings.existed ? 'updated' : 'created';
1687
2072
  // installed_plugins.json v2 merge (create with version 2 when absent)