autonomous-sdlc-harness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +7 -0
  3. package/README.md +24 -0
  4. package/dist/cli.js +194 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/config.js +561 -0
  7. package/dist/commands/config.js.map +1 -0
  8. package/dist/commands/daemon.js +791 -0
  9. package/dist/commands/daemon.js.map +1 -0
  10. package/dist/commands/doctor.js +336 -0
  11. package/dist/commands/doctor.js.map +1 -0
  12. package/dist/commands/init.js +2023 -0
  13. package/dist/commands/init.js.map +1 -0
  14. package/dist/commands/registry.js +42 -0
  15. package/dist/commands/registry.js.map +1 -0
  16. package/dist/config/check.js +505 -0
  17. package/dist/config/check.js.map +1 -0
  18. package/dist/config/io.js +177 -0
  19. package/dist/config/io.js.map +1 -0
  20. package/dist/config/model.js +406 -0
  21. package/dist/config/model.js.map +1 -0
  22. package/dist/core/errors.js +71 -0
  23. package/dist/core/errors.js.map +1 -0
  24. package/dist/core/git.js +537 -0
  25. package/dist/core/git.js.map +1 -0
  26. package/dist/core/json.js +125 -0
  27. package/dist/core/json.js.map +1 -0
  28. package/dist/core/layerCoverage.js +141 -0
  29. package/dist/core/layerCoverage.js.map +1 -0
  30. package/dist/core/layerGapRemedy.js +62 -0
  31. package/dist/core/layerGapRemedy.js.map +1 -0
  32. package/dist/core/nameList.js +23 -0
  33. package/dist/core/nameList.js.map +1 -0
  34. package/dist/core/paths.js +153 -0
  35. package/dist/core/paths.js.map +1 -0
  36. package/dist/core/prompt.js +206 -0
  37. package/dist/core/prompt.js.map +1 -0
  38. package/dist/core/repoPaths.js +55 -0
  39. package/dist/core/repoPaths.js.map +1 -0
  40. package/dist/core/report.js +150 -0
  41. package/dist/core/report.js.map +1 -0
  42. package/dist/core/templating.js +88 -0
  43. package/dist/core/templating.js.map +1 -0
  44. package/dist/core/writer.js +479 -0
  45. package/dist/core/writer.js.map +1 -0
  46. package/dist/daemon/backend.js +180 -0
  47. package/dist/daemon/backend.js.map +1 -0
  48. package/dist/daemon/units.js +380 -0
  49. package/dist/daemon/units.js.map +1 -0
  50. package/dist/detect/nestedApplication.js +79 -0
  51. package/dist/detect/nestedApplication.js.map +1 -0
  52. package/dist/detect/presets.js +2033 -0
  53. package/dist/detect/presets.js.map +1 -0
  54. package/dist/detect/signals.js +1368 -0
  55. package/dist/detect/signals.js.map +1 -0
  56. package/dist/doctor/checks.js +3530 -0
  57. package/dist/doctor/checks.js.map +1 -0
  58. package/dist/generators/claudeContext.js +588 -0
  59. package/dist/generators/claudeContext.js.map +1 -0
  60. package/dist/generators/githooks.js +446 -0
  61. package/dist/generators/githooks.js.map +1 -0
  62. package/dist/generators/harnessConfig.js +632 -0
  63. package/dist/generators/harnessConfig.js.map +1 -0
  64. package/dist/generators/notifications.js +191 -0
  65. package/dist/generators/notifications.js.map +1 -0
  66. package/dist/generators/outerLoopScripts.js +165 -0
  67. package/dist/generators/outerLoopScripts.js.map +1 -0
  68. package/dist/generators/permissionProfile.js +1172 -0
  69. package/dist/generators/permissionProfile.js.map +1 -0
  70. package/dist/generators/projectSettings.js +322 -0
  71. package/dist/generators/projectSettings.js.map +1 -0
  72. package/dist/generators/repoRoot.js +417 -0
  73. package/dist/generators/repoRoot.js.map +1 -0
  74. package/dist/generators/scripts.js +557 -0
  75. package/dist/generators/scripts.js.map +1 -0
  76. package/dist/generators/stateDir.js +221 -0
  77. package/dist/generators/stateDir.js.map +1 -0
  78. package/dist/machine/paths.js +111 -0
  79. package/dist/machine/paths.js.map +1 -0
  80. package/dist/machine/plugins.js +224 -0
  81. package/dist/machine/plugins.js.map +1 -0
  82. package/dist/machine/registry.js +330 -0
  83. package/dist/machine/registry.js.map +1 -0
  84. package/package.json +23 -0
  85. package/scripts/README.md +13 -0
  86. package/scripts/daemon/launchd.plist.template +59 -0
  87. package/scripts/daemon/systemd.service.template +58 -0
  88. package/templates/README.md +15 -0
  89. package/templates/claude/CLAUDE.md +54 -0
  90. package/templates/claude/README.md +5 -0
  91. package/templates/claude/context/api.md +29 -0
  92. package/templates/claude/context/conventions.md +23 -0
  93. package/templates/claude/context/data-layer.md +28 -0
  94. package/templates/claude/context/data-storage.md +29 -0
  95. package/templates/claude/context/docs-catalog.md +29 -0
  96. package/templates/claude/context/domain.md +28 -0
  97. package/templates/claude/context/layer.md +20 -0
  98. package/templates/claude/context/module.md +30 -0
  99. package/templates/claude/context/package.md +29 -0
  100. package/templates/claude/context/presentation.md +32 -0
  101. package/templates/claude/context/state-slices.md +28 -0
  102. package/templates/claude/context/tests.md +28 -0
  103. package/templates/claude/harness-task-offer.md +58 -0
  104. package/templates/claude/push-notify.env.example +21 -0
  105. package/templates/claude/qa-accounts.env.example +38 -0
  106. package/templates/claude/qa_test_scenarios.md +110 -0
  107. package/templates/claude/settings.autonomous.json +93 -0
  108. package/templates/claude/settings.autonomous.qa.json +36 -0
  109. package/templates/githooks/README.md +3 -0
  110. package/templates/githooks/pre-push +72 -0
  111. package/templates/repo/README.md +3 -0
  112. package/templates/repo/gitattributes +16 -0
  113. package/templates/repo/gitignore +61 -0
  114. package/templates/repo/gitignore.qa +25 -0
  115. package/templates/repo/mcp.json +17 -0
  116. package/templates/scripts/README.md +5 -0
  117. package/templates/scripts/autonomous-format-stream.sh +95 -0
  118. package/templates/scripts/autonomous-notify.sh +337 -0
  119. package/templates/scripts/autonomous-watcher.sh +3087 -0
  120. package/templates/scripts/cleanup-merged-worktrees.sh +327 -0
  121. package/templates/scripts/commit-on-branch.sh +288 -0
  122. package/templates/scripts/create-worktree.sh +360 -0
  123. package/templates/scripts/deploy.sh +47 -0
  124. package/templates/scripts/lib/harness-run-lib.sh +1481 -0
  125. package/templates/scripts/push-branch.sh +140 -0
  126. package/templates/scripts/refresh-branch.sh +244 -0
  127. package/templates/scripts/restart-watcher.sh +401 -0
  128. package/templates/scripts/scratch-run.sh +302 -0
  129. package/templates/scripts/setup-worktree.sh +262 -0
  130. package/templates/scripts/start-dev-server.sh +99 -0
  131. package/templates/scripts/test.sh +50 -0
  132. package/templates/scripts/typecheck.sh +50 -0
  133. package/templates/state-dir/README-root.md +13 -0
  134. package/templates/state-dir/README.md +9 -0
  135. package/templates/state-dir/architecture_branch_review_point_reviews/README.md +9 -0
  136. package/templates/state-dir/architecture_branch_reviews/README.md +9 -0
  137. package/templates/state-dir/architecture_reviews/README.md +9 -0
  138. package/templates/state-dir/architecture_user_review_reviews/README.md +9 -0
  139. package/templates/state-dir/autonomous_inbox/README.md +9 -0
  140. package/templates/state-dir/autonomous_logs/README.md +9 -0
  141. package/templates/state-dir/branch_statistics/README.md +9 -0
  142. package/templates/state-dir/business_parity_branch_review_point_reviews/README.md +9 -0
  143. package/templates/state-dir/business_parity_branch_reviews/README.md +9 -0
  144. package/templates/state-dir/business_parity_reviews/README.md +9 -0
  145. package/templates/state-dir/business_parity_user_review_reviews/README.md +9 -0
  146. package/templates/state-dir/clarification_digests/README.md +9 -0
  147. package/templates/state-dir/clarifications/README.md +9 -0
  148. package/templates/state-dir/code_reviews/README.md +9 -0
  149. package/templates/state-dir/dispatch_additions/README.md +19 -0
  150. package/templates/state-dir/docs_catalog/README.md +9 -0
  151. package/templates/state-dir/flow_progress/README.md +9 -0
  152. package/templates/state-dir/improvement_observations/README.md +19 -0
  153. package/templates/state-dir/improvement_suggestions.md +29 -0
  154. package/templates/state-dir/lessons.md +23 -0
  155. package/templates/state-dir/qa_review_point_reviews/README.md +9 -0
  156. package/templates/state-dir/qa_reviews/README.md +9 -0
  157. package/templates/state-dir/review_plan_point_reviews/README.md +9 -0
  158. package/templates/state-dir/review_plan_reviews/README.md +9 -0
  159. package/templates/state-dir/scratch/README.md +11 -0
  160. package/templates/state-dir/skeptic_review_plan_reviews/README.md +9 -0
  161. package/templates/state-dir/skeptic_review_point_reviews/README.md +9 -0
  162. package/templates/state-dir/skeptic_reviews/README.md +9 -0
  163. package/templates/state-dir/story_plans/README.md +9 -0
  164. package/templates/state-dir/task_plan_point_reviews/README.md +9 -0
  165. package/templates/state-dir/task_plan_reviews/README.md +9 -0
  166. package/templates/state-dir/task_plans/README.md +9 -0
  167. package/templates/state-dir/task_prompts/README.md +9 -0
  168. package/templates/state-dir/ui_test_plan_reviews/README.md +9 -0
  169. package/templates/state-dir/ui_test_plans/README.md +9 -0
  170. package/templates/state-dir/user_review_fix_plan_point_reviews/README.md +9 -0
  171. package/templates/state-dir/user_reviews/README.md +9 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Which service manager this host can install the run daemon into, and where the run watcher is.
3
+ *
4
+ * **The rule this module exists to enforce: there is one detection, and `doctor` and `daemon` both
5
+ * import it.** The two commands must agree — `doctor` telling an operator the daemon is installable
6
+ * while `daemon install` refuses, or the reverse, is worse than either answer alone, because the
7
+ * operator has no way to tell which one is wrong. A second copy of the probe in the command that
8
+ * wanted a slightly different message is how that disagreement starts, so the messages live here
9
+ * too, as the `reason` string each result carries.
10
+ *
11
+ * Two invariants hold in every probe below:
12
+ *
13
+ * 1. **`execFileSync` with an argv array, never a shell string** — no `shell: true`, no `which`
14
+ * pipeline, no interpolation. A pipeline would be a compound command, which an unattended run's
15
+ * permission profile cannot allow-list as one literal, and which stalls rather than failing.
16
+ * 2. **Every probe is bounded and its output discarded.** A service manager that hangs must cost a
17
+ * timeout rather than the run: `doctor` is the command an operator reaches for *because*
18
+ * something is wrong with the host, and it has to answer.
19
+ *
20
+ * ## The watcher is the repository's, not this package's
21
+ *
22
+ * The daemon runs one script — the run watcher — and `init` writes it into the adopter's configured
23
+ * `scriptsDir` with the rest of the outer-loop set. {@link resolveWatcherPath} therefore joins the
24
+ * repository root to that configured directory, and {@link WATCHER_ABSENCE_MESSAGE} is the one
25
+ * sentence both commands use when nothing is there: `doctor` reports it as a warning, so a
26
+ * repository whose watcher was deleted still exits 0, and `daemon install` refuses, because
27
+ * installing a unit whose `ExecStart` points at nothing gives a service that restarts forever and
28
+ * logs nothing anyone reads.
29
+ *
30
+ * The path is **repo-relative and not a configuration key of its own**: `scriptsDir` already is one,
31
+ * and the file name comes from `generators/outerLoopScripts.ts`'s table — the single declaration of
32
+ * which outer-loop files ship and where each lands — so the writer and the daemon cannot disagree
33
+ * about what the daemon runs. Resolving against the repository is also what keeps the rendered
34
+ * unit's `ExecStart` inside the same repository its `WorkingDirectory` already names: a CLI invoked
35
+ * through `npx` lives in a cache directory that is deleted out from under a long-running service,
36
+ * and a package-relative program would have made that path a daemon's `ExecStart`. `--watcher`
37
+ * overrides it for developing against a watcher that is not the written one.
38
+ */
39
+ import { execFileSync } from 'node:child_process';
40
+ import { existsSync } from 'node:fs';
41
+ import { join, resolve } from 'node:path';
42
+ import { homeRoot } from '../core/paths.js';
43
+ import { outerLoopScriptPath, WATCHER_SCRIPT_NAME } from '../generators/outerLoopScripts.js';
44
+ /** `launchctl`'s `--version`-equivalent: it prints the running launchd's version and exits 0. */
45
+ const LAUNCHCTL_PROBE = Object.freeze(['version']);
46
+ /** `systemctl`'s version probe — resolution of the binary only, not of a user instance. */
47
+ const SYSTEMCTL_PROBE = Object.freeze(['--version']);
48
+ /**
49
+ * The per-user-instance probe.
50
+ *
51
+ * `--user show-environment` exits 0 exactly when the calling account's systemd manager is reachable
52
+ * over its bus, which is the condition a user unit needs and the binary's presence does not imply —
53
+ * a container image or an `ssh` session with no user bus has `systemctl` and no manager to talk to.
54
+ * `is-system-running` is the obvious alternative and is wrong here: it reports `degraded` with a
55
+ * non-zero status on a perfectly usable host, which would demote a working backend to `none`.
56
+ */
57
+ const SYSTEMD_USER_PROBE = Object.freeze(['--user', 'show-environment']);
58
+ /** Longest a probe may take before it is treated as "this tool did not answer". */
59
+ const PROBE_TIMEOUT_MS = 5_000;
60
+ /** The launchd user-agent directory, relative to the account home. */
61
+ const LAUNCHD_UNIT_DIR = Object.freeze(['Library', 'LaunchAgents']);
62
+ /** The systemd user-unit directory, relative to the account home. */
63
+ const SYSTEMD_UNIT_DIR = Object.freeze(['.config', 'systemd', 'user']);
64
+ /**
65
+ * The single sentence both commands use for the watcher's absence.
66
+ *
67
+ * Exported rather than written twice so a `doctor` warning and a `daemon install` refusal cannot
68
+ * describe the same condition differently — the second-worst outcome after describing it wrongly.
69
+ *
70
+ * It names the two ways a wired repository reaches this state, because they are the two the operator
71
+ * has to choose between: the file was removed, or `scriptsDir` was changed and `init` has not been
72
+ * run since. Both are repaired by the same command, which is why the remedy is one sentence.
73
+ */
74
+ export const WATCHER_ABSENCE_MESSAGE = 'the run watcher is written into the configured scriptsDir by `init`, so nothing there means it was removed, or scriptsDir was changed without a re-run';
75
+ /**
76
+ * Run a tool with a fixed argument vector and report whether it answered.
77
+ *
78
+ * Both failure modes collapse to `false` on purpose: a tool that is not on PATH and a tool that
79
+ * exited non-zero are the same answer to the only question asked — can this host's service manager
80
+ * be driven from here. The distinction that *does* matter, between "no systemctl" and "systemctl
81
+ * with no user instance", is drawn by which probe failed, not by how it failed.
82
+ */
83
+ function toolResponds(command, args) {
84
+ try {
85
+ execFileSync(command, [...args], {
86
+ stdio: ['ignore', 'ignore', 'ignore'],
87
+ timeout: PROBE_TIMEOUT_MS,
88
+ windowsHide: true,
89
+ });
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ /** `~/Library/LaunchAgents` — where a launchd user agent is installed. */
97
+ export function launchdUnitDir() {
98
+ return join(homeRoot(), ...LAUNCHD_UNIT_DIR);
99
+ }
100
+ /** `~/.config/systemd/user` — where a systemd user unit is installed. */
101
+ export function systemdUnitDir() {
102
+ return join(homeRoot(), ...SYSTEMD_UNIT_DIR);
103
+ }
104
+ /**
105
+ * Detect the service manager the run daemon can be installed into.
106
+ *
107
+ * The order is fixed and first-match-wins:
108
+ *
109
+ * 1. **launchd**, when this is macOS *and* `launchctl` answers. The platform check comes first
110
+ * because launchd is not something a non-macOS host has a working `launchctl` for.
111
+ * 2. **systemd**, when `systemctl` answers *and* the calling account's user instance is reachable.
112
+ * Both halves are required: a `systemctl` with no user manager behind it produces a unit file
113
+ * that installs and never starts, which is the failure this second probe exists to turn into an
114
+ * up-front `none` with a fixable reason.
115
+ * 3. **none** otherwise, always with a reason naming what was missing. `none` is a statement about
116
+ * the host, not an error — the caller decides whether it is fatal (`daemon install`) or a
117
+ * reported condition (`doctor`).
118
+ */
119
+ export function detectBackend() {
120
+ const platform = process.platform;
121
+ const isMac = platform === 'darwin';
122
+ if (isMac && toolResponds('launchctl', LAUNCHCTL_PROBE)) {
123
+ return {
124
+ kind: 'launchd',
125
+ reason: 'macOS with a responding launchctl: the run daemon installs as a launchd user agent',
126
+ unitPath: launchdUnitDir(),
127
+ };
128
+ }
129
+ if (toolResponds('systemctl', SYSTEMCTL_PROBE)) {
130
+ if (toolResponds('systemctl', SYSTEMD_USER_PROBE)) {
131
+ return {
132
+ kind: 'systemd',
133
+ reason: 'systemd with a reachable per-user instance: the run daemon installs as a user unit',
134
+ unitPath: systemdUnitDir(),
135
+ };
136
+ }
137
+ return {
138
+ kind: 'none',
139
+ reason: 'systemctl responded but this account has no reachable systemd user instance (systemctl --user show-environment failed), so a user unit would install and never start: run the install from a logged-in session of the account that will own the run, or enable lingering for that account',
140
+ };
141
+ }
142
+ if (isMac) {
143
+ return {
144
+ kind: 'none',
145
+ reason: 'this host reports itself as macOS but launchctl did not answer, and there is no systemctl either: the run daemon has no service manager to install into here',
146
+ };
147
+ }
148
+ return {
149
+ kind: 'none',
150
+ reason: `no supported service manager on ${platform}: the run daemon needs launchd (macOS) or a systemd user instance`,
151
+ };
152
+ }
153
+ /**
154
+ * Where the run watcher is, and whether it is there.
155
+ *
156
+ * With no override the answer is `<repoRoot>/<scriptsDir>/<watcher>` — where `init` wrote it, taken
157
+ * from the writer's own {@link outerLoopScriptPath} so the daemon and the generator cannot resolve
158
+ * one configured `scriptsDir` differently. A `--watcher` value is resolved against the current
159
+ * directory instead, so a relative one behaves the way a path typed on a command line does.
160
+ *
161
+ * `present` is a plain existence check and is deliberately not upgraded to an executability or
162
+ * shebang check: the generated copy is the adopter's to edit, and a stricter probe here would start
163
+ * refusing to install a daemon over an edit that is none of this command's business.
164
+ */
165
+ export function resolveWatcherPath({ repoRoot, config, override }) {
166
+ const trimmed = override?.trim();
167
+ const path = trimmed !== undefined && trimmed !== ''
168
+ ? resolve(trimmed)
169
+ : outerLoopScriptPath(repoRoot, config, WATCHER_SCRIPT_NAME);
170
+ return { path, present: existsSync(path) };
171
+ }
172
+ /**
173
+ * The full sentence for a missing watcher, built from {@link WATCHER_ABSENCE_MESSAGE} so the
174
+ * warning `doctor` prints and the refusal `daemon install` throws say the same thing about the same
175
+ * path.
176
+ */
177
+ export function watcherMissingMessage(path) {
178
+ return `no run watcher at ${path}: ${WATCHER_ABSENCE_MESSAGE}. Re-run \`init\` to write it back, or point --watcher at a watcher script of your own`;
179
+ }
180
+ //# sourceMappingURL=backend.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backend.js","sourceRoot":"","sources":["../../src/daemon/backend.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Render the run daemon's unit file for the detected backend, and say where it is installed.
3
+ *
4
+ * **The rule this module exists to enforce: the unit's identifier, its install path and its text are
5
+ * produced together, from one call.** A caller that composed a label itself and read the target path
6
+ * from somewhere else would eventually install a file at one name and address the service by
7
+ * another — a daemon that cannot be stopped by the command that installed it, which is the worst
8
+ * shape of this failure because it survives a reinstall.
9
+ *
10
+ * ## Five non-obvious choices, and where each comes from
11
+ *
12
+ * 1. **The identity is per repository, not per project name.** The launchd label is
13
+ * `com.autonomous-sdlc-harness.watcher.<slug>` and the systemd unit name
14
+ * `harness-watcher@<slug>.service`, where `<slug>` is {@link repoSlug} of the repository the
15
+ * daemon was installed from. Two repositories on one machine therefore install daemons that
16
+ * cannot collide — which two checkouts sharing a `projectName` would have, under a label built
17
+ * from that name. `docs/config.md` §4's `<launchd_label>` row is the register entry and
18
+ * `docs/cli.md` §9 states the per-repository consequence; this module is where the form is
19
+ * implemented, so nothing else in the CLI spells it out.
20
+ * 2. **The install directory comes from the detection result, not from a second table here.**
21
+ * `DaemonBackend.unitPath` is the backend's user-unit directory; this module contributes only
22
+ * the file *name*, which is the half that depends on the repository.
23
+ * 3. **Values are checked before they are substituted, and escaped for the format they land in.** A
24
+ * plist is XML, so a `&` or a `<` in a checkout path would produce a unit no `launchctl` will
25
+ * parse. A systemd unit is neither: `%` introduces a specifier in every directive rendered into
26
+ * here, and `ExecStart=` splits its command line at whitespace, so an unescaped checkout path
27
+ * holding a space runs `/bin/bash` on the first word of it — installed, started, failed and,
28
+ * under `Restart=always`, restarted forever. Both are escaped, per format and per directive; see
29
+ * {@link escapeSystemdValue} and {@link escapeSystemdArgument}. A newline is refused instead — it
30
+ * is not something to render carefully, it is something to report.
31
+ * 4. **On systemd a *concrete* `harness-watcher@<slug>.service` file is written, not a template
32
+ * unit plus an instance.** The `@` is what an operator reads as "one watcher per repository",
33
+ * and it costs nothing to spell it that way: systemd resolves an instance name to a literal
34
+ * file of that exact name *before* it falls back to the `harness-watcher@.service` template, so
35
+ * the file installed here is the one `systemctl --user` acts on. A real template unit would have
36
+ * to reach the repository path through `%i`, i.e. carry the checkout path in the instance name,
37
+ * escaped into it. This design renders that path into `ExecStart` and `WorkingDirectory`
38
+ * instead, quoted and specifier-escaped by choice 3, so a checkout holding a space or a `%` is
39
+ * one the operator never has to encode by hand.
40
+ * 5. **The unit renders a `PATH`, captured from the shell that ran `daemon install`.** A user agent
41
+ * or user unit inherits the *service manager's* environment, not a login shell's: on macOS that
42
+ * is `/usr/bin:/bin:/usr/sbin:/sbin`, where neither a Homebrew package manager nor an agent CLI
43
+ * under `~/.local/bin` resolves — the watcher launches both by name, so a unit without the key
44
+ * fails at the first configured command and, with the failure repaired one binary at a time,
45
+ * at the next. Three answers were weighed. Capturing the installing shell's `PATH` — this one —
46
+ * needs no dependency (this package declares none, and that is a property worth keeping), is
47
+ * deterministic Node with nothing to detect, and is exactly the hand edit that unblocked the
48
+ * first unattended run. Pinning the individual binaries cannot work: the toolchain is the
49
+ * adopter's `commands.*` configuration, which this CLI does not interpret. Sourcing the login
50
+ * shell's profile from the watcher would run arbitrary shell configuration inside the daemon's
51
+ * own process, on every dispatch. Its one weakness is that a captured value goes stale when the
52
+ * toolchain moves; that is a `doctor` question — grading the *installed unit's* `PATH` against
53
+ * the binaries the outer loop runs — rather than one the renderer can answer, and the remedy is
54
+ * `daemon install --force`. An absent or empty `PATH` renders **no** key: `PATH=""` overrides
55
+ * the service manager's own directories with nothing, which is strictly worse than the default
56
+ * the caller then warns about. The value it does render is escaped for a **quoted** destination
57
+ * on systemd — `Environment="PATH=…"` is quoted because an entry may hold a space, which makes a
58
+ * `"` or a `\` in one the hazard choice 3's `ExecStart` escaping exists for.
59
+ *
60
+ * ## Scope
61
+ *
62
+ * This module renders and computes; it writes nothing. Installing the rendered text, reloading the
63
+ * service manager and starting or stopping the daemon are the `daemon` subcommand's, and keeping
64
+ * them apart is what lets the rendering be tested without a service manager present.
65
+ */
66
+ import { readFileSync } from 'node:fs';
67
+ import { join } from 'node:path';
68
+ import { DEFAULTS } from '../config/model.js';
69
+ import { EXIT, HarnessError } from '../core/errors.js';
70
+ import { defaultProjectName, packageScriptsDir } from '../core/paths.js';
71
+ import { normalizeRepoDir } from '../core/repoPaths.js';
72
+ import { renderTemplate } from '../core/templating.js';
73
+ /** The unit templates' subdirectory inside this package's `scripts/` directory. */
74
+ const TEMPLATE_DIR = 'daemon';
75
+ /** One template per installable backend. `none` has no row, because it has no unit. */
76
+ const UNIT_TEMPLATES = {
77
+ launchd: 'launchd.plist.template',
78
+ systemd: 'systemd.service.template',
79
+ };
80
+ /** Reverse-DNS prefix of the launchd label; {@link repoSlug} completes it. */
81
+ const LAUNCHD_LABEL_PREFIX = 'com.autonomous-sdlc-harness.watcher';
82
+ /** The systemd unit name, either side of {@link repoSlug}. */
83
+ const SYSTEMD_UNIT_PREFIX = 'harness-watcher@';
84
+ const SYSTEMD_UNIT_SUFFIX = '.service';
85
+ /** The longest a slug may be. Applied last, keeping the tail — see {@link repoSlug}. */
86
+ const SLUG_MAX_LENGTH = 64;
87
+ /**
88
+ * What a derived slug may be: non-empty, and lowercase letters, digits and `-` only.
89
+ *
90
+ * This is the check `projectName` used to carry, moved to the value that now names the service. It
91
+ * refuses a *derivation* rather than a configuration — {@link repoSlug} is total over any string,
92
+ * so nothing an adopter can write reaches it — which is why failing it exits {@link EXIT.INTERNAL}.
93
+ */
94
+ const SLUG_PATTERN = /^[a-z0-9-]+$/;
95
+ /**
96
+ * The repository's machine-unique identifier: its own absolute path, lowercased, with every
97
+ * character outside `[a-z0-9]` replaced by `-`, runs collapsed, leading and trailing `-` stripped,
98
+ * and the result cut to {@link SLUG_MAX_LENGTH} characters **keeping the tail** — the tail is the
99
+ * distinguishing part, since two checkouts on one machine usually share a long prefix.
100
+ *
101
+ * **This function has a twin, and a test that keeps them equal.** The generated
102
+ * `<scriptsDir>/lib/harness-run-lib.sh` defines `hr_repo_slug` doing the same transformation for
103
+ * the watcher, the notifier and the machine-level usage lane, so the daemon a repository installs
104
+ * and the run artifacts that repository writes are keyed alike. `test/daemon.test.mjs` runs both
105
+ * halves over one path and compares — the only thing stopping them drifting. **Change one, change
106
+ * the other in the same commit**, and keep these three properties, each of which the shell half
107
+ * also has:
108
+ *
109
+ * - **Truncation is last**, so a truncated slug may *begin* with `-`. Reordering it to strip after
110
+ * cutting would be tidier and would silently disagree with the shell.
111
+ * - **Letters and digits are ASCII-only**, on both sides: the shell half runs under `LC_ALL=C` so
112
+ * that its `[!a-z0-9]` cannot admit `é` through a locale's collation, and the fold here is
113
+ * `[A-Z]` rather than `toLowerCase()` for the mirror-image reason — a locale-aware fold turns `İ`
114
+ * into an ASCII `i` where the shell yields a `-`. An accented letter is a separator, never a
115
+ * letter it resembles.
116
+ * - **No hash, no truncating digest.** Both halves have to be one readable transformation an
117
+ * operator can reproduce by eye from the path they are looking at.
118
+ *
119
+ * The argument is the repository the daemon is being installed for — `daemon install` resolves it
120
+ * from its own working directory. The shell half additionally resolves the *main* checkout first,
121
+ * so every worktree of one repository answers the same slug; nothing here needs that step, because
122
+ * the unit's `WorkingDirectory` is this same root and a unit installed from a checkout is a unit
123
+ * for that checkout.
124
+ *
125
+ * Not to be confused with `parseRepoSlug` in `core/paths.ts`, which is `<owner>/<repository>` off a
126
+ * git remote URL and has nothing to do with a service identifier.
127
+ */
128
+ export function repoSlug(repoRoot) {
129
+ const slug = repoRoot
130
+ .replace(/[A-Z]/g, (letter) => letter.toLowerCase())
131
+ .replace(/[^a-z0-9]/g, '-')
132
+ .replace(/-+/g, '-')
133
+ .replace(/^-+|-+$/g, '')
134
+ .slice(-SLUG_MAX_LENGTH);
135
+ if (!SLUG_PATTERN.test(slug)) {
136
+ throw new HarnessError(`the daemon identifier derived from ${JSON.stringify(repoRoot)} is ${JSON.stringify(slug)}, which cannot name a service: the derivation admits only lowercase letters, digits and '-' and must produce at least one character, so this is a fault in this CLI rather than in the repository`, EXIT.INTERNAL);
137
+ }
138
+ return slug;
139
+ }
140
+ /**
141
+ * The project name the unit files *describe* themselves with: the configured `projectName`, or the
142
+ * repository directory name the schema says `init` defaults it to.
143
+ *
144
+ * Descriptive only, since decision 21 — it reaches a plist comment, through {@link commentSafe} for
145
+ * what that destination forbids, and a systemd `Description=`, and no longer the label or the file
146
+ * name. That is why it carries no pattern check any more: the check that used to live here is
147
+ * {@link SLUG_PATTERN}, on the value that names the service.
148
+ * `assertSingleLine` and the XML escaping still apply to it, as they do to every substituted value.
149
+ */
150
+ function descriptiveProjectName(config, repoRoot) {
151
+ const configured = config.projectName?.trim();
152
+ return configured !== undefined && configured !== '' ? configured : defaultProjectName(repoRoot);
153
+ }
154
+ /** XML text-node escaping, for the values substituted into the plist's `<string>` elements. */
155
+ function escapeXml(value) {
156
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
157
+ }
158
+ /**
159
+ * Separate adjacent hyphens in a value whose plist destination is a **comment**, where XML 1.0 §2.5
160
+ * forbids `--` and {@link escapeXml} — which touches only `&`, `<` and `>` — leaves one standing.
161
+ *
162
+ * The guarantee is the property, not one substitution: no two hyphens survive adjacent, for a run of
163
+ * **any** length, since every hyphen followed by another takes a trailing space (`notes--app` →
164
+ * `notes- -app`, `notes---app` → `notes- - -app`).
165
+ *
166
+ * `projectName` is the plist's only comment-destined substitution; `label` appears in that block too
167
+ * but cannot carry an adjacent pair, because {@link repoSlug} collapses runs of `-`. The rewrite
168
+ * reaches descriptive comment text and never a value the service manager reads, which is why
169
+ * mangling it is acceptable where refusing a legal `projectName` would not be. The systemd arm is
170
+ * untouched: `Description=` is a directive, not a comment.
171
+ */
172
+ function commentSafe(value) {
173
+ return value.replace(/-(?=-)/g, '- ');
174
+ }
175
+ /**
176
+ * systemd escaping for a value that is a whole directive argument — `WorkingDirectory=`,
177
+ * `Description=`, the `StandardOutput=append:` paths.
178
+ *
179
+ * Only `%` needs it: those directives take the rest of the line literally, but every one of them is
180
+ * specifier-expanded first, so a checkout under `/home/ada/100%-mine` would resolve `%-` and the
181
+ * service would run somewhere else. `%%` is systemd's own escape for a literal `%`.
182
+ */
183
+ function escapeSystemdValue(value) {
184
+ return value.replace(/%/g, '%%');
185
+ }
186
+ /**
187
+ * systemd escaping for a value substituted **inside quotes** — `ExecStart=`'s program path, and the
188
+ * `PATH` inside `Environment="PATH=…"`, both of which the template quotes.
189
+ *
190
+ * `ExecStart=` splits at whitespace before anything runs and a `PATH` entry may hold a space, which
191
+ * is why each is quoted rather than trusted; a `"` or a `\` in one would end or eat that quoting, so
192
+ * both are backslash-escaped. Specifiers expand here too, so {@link escapeSystemdValue} still
193
+ * applies.
194
+ */
195
+ function escapeSystemdArgument(value) {
196
+ return escapeSystemdValue(value.replace(/[\\"]/g, (character) => `\\${character}`));
197
+ }
198
+ /** The one token whose systemd destination is a command-line argument rather than a whole value. */
199
+ const SYSTEMD_ARGUMENT_TOKENS = new Set(['watcherPath']);
200
+ /**
201
+ * The `{{environment}}` stanza: the whole environment key for this backend, or nothing at all.
202
+ *
203
+ * The stanza is one token rather than a `{{envPath}}` inside a fixed key because the empty case has
204
+ * to remove the *key*, not leave it holding `""` (choice 5), and `renderTemplate` substitutes values
205
+ * and knows no conditional sections. `escaped` is the already-escaped value — {@link escapeXml} for
206
+ * the plist and {@link escapeSystemdArgument} for the quoted systemd directive below, applied by
207
+ * {@link renderUnit} — since this function's own output is markup that must not be escaped again.
208
+ *
209
+ * The trailing newline is what keeps both cases readable: each template puts `{{environment}}` on a
210
+ * line of its own, so a rendered stanza is followed by a blank line and an omitted one leaves the
211
+ * blank line that was already there.
212
+ */
213
+ function environmentStanza(kind, escaped) {
214
+ if (escaped === undefined)
215
+ return '';
216
+ return kind === 'launchd'
217
+ ? ` <key>EnvironmentVariables</key>\n <dict>\n <key>PATH</key>\n <string>${escaped}</string>\n </dict>\n`
218
+ : `Environment="PATH=${escaped}"\n`;
219
+ }
220
+ /** The plist's environment dictionary — {@link environmentStanza} renders it — and one named entry inside it. */
221
+ const PLIST_ENVIRONMENT = /<key>\s*EnvironmentVariables\s*<\/key>\s*<dict>([\s\S]*?)<\/dict>/;
222
+ const plistEntry = (key) => new RegExp(`<key>\\s*${key}\\s*</key>\\s*<string>([\\s\\S]*?)</string>`);
223
+ /**
224
+ * The systemd form, quoted as the renderer writes it and bare as a hand edit may leave it.
225
+ *
226
+ * The quoted alternative admits `\"` and `\\`, because that is what {@link escapeSystemdArgument}
227
+ * writes into it: a `[^"]*` value would stop at the first escaped quote and read back a truncated
228
+ * `PATH`.
229
+ */
230
+ const systemdDirective = (key) => new RegExp(`^Environment=(?:"${key}=((?:[^"\\\\]|\\\\.)*)"|${key}=(.*))$`, 'gm');
231
+ /**
232
+ * One environment value an installed unit gives the daemon, or `undefined` when the unit does not set
233
+ * it — `PATH`, and the agent CLI variable beside it.
234
+ *
235
+ * The two shapes are {@link environmentStanza}'s, read back: a plist dictionary whose value is
236
+ * XML-escaped, and a systemd `Environment=` line whose value is specifier-escaped and, inside the
237
+ * quoting the renderer writes, backslash-escaped as well. Each is un-escaped by the inverse of the
238
+ * escape that wrote it — and in that order — so a toolchain directory holding an `&`, a `%`, a `"` or
239
+ * a `\` is compared as the path it is rather than as the markup it was stored as. A *bare* directive
240
+ * is left as written, because nothing here escaped it: it can only be a hand edit.
241
+ *
242
+ * The key is interpolated into both patterns and is always one of the caller's own literals, so it
243
+ * carries nothing a regular expression reads.
244
+ *
245
+ * The parse is deliberately tolerant of hand edits — the unit is the operator's file once it is
246
+ * installed, and a key the renderer never writes (the agent CLI variable `doctor` reads) can only
247
+ * arrive that way — which is why the systemd side takes the **last** matching directive: that is what
248
+ * systemd itself does with a second one.
249
+ *
250
+ * Exported because two consumers read an installed unit back: `doctor`'s `daemon-path` check, and
251
+ * `daemon install`'s comparison of the `PATH` it is about to write against the one on disk. It lives
252
+ * beside the escaping it inverts so that a change to {@link environmentStanza} and a change to this
253
+ * parse are one edit in one file — and so the round trip against {@link renderUnit}'s escaping, the
254
+ * property the un-escaping claims and cannot show on its own, has both sides in one module.
255
+ */
256
+ export function unitEnvValue(kind, text, key) {
257
+ if (kind === 'launchd') {
258
+ const dict = PLIST_ENVIRONMENT.exec(text)?.[1];
259
+ if (dict === undefined)
260
+ return undefined;
261
+ const value = plistEntry(key).exec(dict)?.[1];
262
+ return value === undefined ? undefined : value.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
263
+ }
264
+ let last;
265
+ for (const match of text.matchAll(systemdDirective(key))) {
266
+ last = match[1] !== undefined ? { raw: match[1], quoted: true } : { raw: match[2] ?? '', quoted: false };
267
+ }
268
+ if (last === undefined)
269
+ return undefined;
270
+ // The escape's steps, undone in reverse: `%%` first, then the backslashes the quoting needed.
271
+ const withLiteralPercent = last.raw.replace(/%%/g, '%');
272
+ return last.quoted ? withLiteralPercent.replace(/\\(["\\])/g, '$1') : withLiteralPercent;
273
+ }
274
+ /**
275
+ * Refuse a value carrying a line break before it is substituted anywhere.
276
+ *
277
+ * A newline in a systemd unit's value ends the directive and starts another, and in a plist it
278
+ * survives into a string the service manager then acts on. Both are ways for a path to become a
279
+ * *setting*, so the check is applied to every value and to both backends rather than to the one
280
+ * format where the consequence is easiest to demonstrate.
281
+ */
282
+ function assertSingleLine(token, value) {
283
+ if (/[\r\n]/.test(value)) {
284
+ throw new HarnessError(`the value for {{${token}}} contains a line break (${JSON.stringify(value)}), which would split the generated unit file into directives nobody wrote: move the checkout, or set the offending path in harness.config.json to one without a newline in it`);
285
+ }
286
+ }
287
+ /**
288
+ * Read a unit template from this package's `scripts/daemon/` directory.
289
+ *
290
+ * It is read from `scripts/` rather than through `readTemplate` because these two files are not
291
+ * adopter-facing generator templates: they are shipped assets of the daemon, which is why they sit
292
+ * beside the watcher the daemon runs. A missing one is a packaging fault the adopter cannot act on,
293
+ * so it exits {@link EXIT.INTERNAL} with the path named.
294
+ */
295
+ function readUnitTemplate(file) {
296
+ const absolute = join(packageScriptsDir(), TEMPLATE_DIR, file);
297
+ try {
298
+ return readFileSync(absolute, 'utf8');
299
+ }
300
+ catch (error) {
301
+ if (error?.code === 'ENOENT') {
302
+ throw new HarnessError(`daemon unit template missing from the installed package: ${absolute}`, EXIT.INTERNAL);
303
+ }
304
+ throw error;
305
+ }
306
+ }
307
+ /**
308
+ * Render a unit template through {@link renderTemplate}.
309
+ *
310
+ * **`assertNoneSurvive` is on**: the values are a service label, three absolute paths and an
311
+ * environment stanza built from a fourth, so one carrying `{{…}}` means a half-rendered unit
312
+ * installed into a service manager — the failure this module's header calls the worst shape,
313
+ * because it survives a reinstall.
314
+ */
315
+ function render(file, values) {
316
+ return renderTemplate(readUnitTemplate(file), values, {
317
+ describe: `the daemon unit template ${file}`,
318
+ assertNoneSurvive: true,
319
+ });
320
+ }
321
+ /**
322
+ * Render the daemon's unit for the detected backend, and compute where it is installed and what it
323
+ * is then called.
324
+ *
325
+ * - **launchd** — `~/Library/LaunchAgents/com.autonomous-sdlc-harness.watcher.<slug>.plist`.
326
+ * - **systemd** — `~/.config/systemd/user/harness-watcher@<slug>.service`.
327
+ * - **`none`** — throws, naming the platform and carrying detection's own reason, because a caller
328
+ * that reached this function with no backend has a question with no answer rather than a value to
329
+ * fall back on.
330
+ *
331
+ * The install directory is the backend's `unitPath`; only the file name is computed here.
332
+ */
333
+ export function renderUnit({ backend, config, repoRoot, watcherPath, envPath }) {
334
+ if (backend.kind === 'none') {
335
+ throw new HarnessError(`no service manager to install the run daemon into on ${process.platform}: ${backend.reason}`);
336
+ }
337
+ const unitDir = backend.unitPath;
338
+ if (unitDir === undefined) {
339
+ throw new HarnessError(`the ${backend.kind} backend was detected without a unit directory, so there is nowhere to install the daemon: detection and installation disagree, which is a fault in this CLI`, EXIT.INTERNAL);
340
+ }
341
+ const projectName = descriptiveProjectName(config, repoRoot);
342
+ const stateDirAbs = join(repoRoot, normalizeRepoDir(config.stateDir ?? DEFAULTS.stateDir));
343
+ const slug = repoSlug(repoRoot);
344
+ const label = backend.kind === 'launchd'
345
+ ? `${LAUNCHD_LABEL_PREFIX}.${slug}`
346
+ : `${SYSTEMD_UNIT_PREFIX}${slug}${SYSTEMD_UNIT_SUFFIX}`;
347
+ const fileName = backend.kind === 'launchd' ? `${label}.plist` : label;
348
+ // Undefined and empty are one answer — no environment key — so the distinction is collapsed here
349
+ // rather than in each of the two places that would otherwise have to test for both.
350
+ const resolvedEnvPath = envPath !== undefined && envPath.trim() !== '' ? envPath : undefined;
351
+ const values = { label, projectName, watcherPath, repoRoot, stateDirAbs };
352
+ for (const [token, value] of Object.entries(values))
353
+ assertSingleLine(token, value);
354
+ if (resolvedEnvPath !== undefined)
355
+ assertSingleLine('envPath', resolvedEnvPath);
356
+ // Escaped per format: XML for the plist, and for systemd per directive — the command-line
357
+ // argument quoted-and-escaped, every other value specifier-escaped. Escaping a value for the
358
+ // wrong format puts the escape text into the path the service actually runs.
359
+ const escape = backend.kind === 'launchd'
360
+ ? (token, value) => escapeXml(token === 'projectName' ? commentSafe(value) : value)
361
+ : (token, value) => SYSTEMD_ARGUMENT_TOKENS.has(token) ? escapeSystemdArgument(value) : escapeSystemdValue(value);
362
+ const substituted = Object.fromEntries(Object.entries(values).map(([token, value]) => [token, escape(token, value)]));
363
+ // Escaped for its own destination rather than through the map above: the systemd stanza renders a
364
+ // *quoted* `Environment="PATH=…"`, so a `"` or a `\` in a toolchain directory would end or eat that
365
+ // quoting — the same reason `ExecStart`'s path takes the argument escaper (choice 3), on the same
366
+ // kind of destination. The plist arm quotes nothing, so it stays on XML escaping.
367
+ const environment = environmentStanza(backend.kind, resolvedEnvPath === undefined
368
+ ? undefined
369
+ : backend.kind === 'launchd'
370
+ ? escapeXml(resolvedEnvPath)
371
+ : escapeSystemdArgument(resolvedEnvPath));
372
+ return {
373
+ text: render(UNIT_TEMPLATES[backend.kind], { ...substituted, environment }),
374
+ targetPath: join(unitDir, fileName),
375
+ label,
376
+ projectName,
377
+ envPath: resolvedEnvPath,
378
+ };
379
+ }
380
+ //# sourceMappingURL=units.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"units.js","sourceRoot":"","sources":["../../src/daemon/units.ts"],"names":[],"mappings":"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"}