@appchy/jarvis 0.1.37 → 0.1.39

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 (108) hide show
  1. package/bin/config-change.mjs +1 -1
  2. package/bin/jarvis.mjs +8 -12
  3. package/bin/pre-tool-use.mjs +2 -2
  4. package/bin/session-start.mjs +1 -1
  5. package/bin/stop.mjs +1 -1
  6. package/bin/user-prompt-submit.mjs +1 -1
  7. package/dist/bin.js +10168 -37341
  8. package/dist/bin.js.map +1 -1
  9. package/dist/data/backends.mjs +469 -0
  10. package/dist/data/chunk-7REP35VA.mjs +462 -0
  11. package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
  12. package/dist/data/chunk-AYOJSS2F.mjs +72 -0
  13. package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
  14. package/dist/data/chunk-YWSWQEJN.mjs +138 -0
  15. package/dist/data/embedders.mjs +80 -0
  16. package/dist/data/finders.mjs +35 -0
  17. package/dist/data/index.mjs +137 -0
  18. package/dist/data/linkers.mjs +83 -0
  19. package/dist/data/mcp.mjs +2624 -0
  20. package/dist/data/persistences.mjs +8 -0
  21. package/dist/data/rerankers.mjs +52 -0
  22. package/dist/data/stores.mjs +38 -0
  23. package/dist/hooks/config-change.js +7 -29
  24. package/dist/hooks/config-change.js.map +1 -1
  25. package/dist/hooks/pre-tool-use.js +129 -32
  26. package/dist/hooks/pre-tool-use.js.map +1 -1
  27. package/dist/hooks/session-start.js +72 -37
  28. package/dist/hooks/session-start.js.map +1 -1
  29. package/dist/hooks/stop.js +140 -32
  30. package/dist/hooks/stop.js.map +1 -1
  31. package/dist/hooks/user-prompt-submit.js +7 -29
  32. package/dist/hooks/user-prompt-submit.js.map +1 -1
  33. package/harness/assets/architecture-template.md +53 -0
  34. package/harness/assets/design-template.md +89 -0
  35. package/harness/assets/domains/architecture.md +43 -0
  36. package/harness/assets/domains/commercial.md +42 -0
  37. package/harness/assets/domains/design.md +42 -0
  38. package/harness/assets/domains/legal.md +43 -0
  39. package/harness/assets/domains/operations.md +39 -0
  40. package/harness/assets/domains/product.md +43 -0
  41. package/harness/assets/domains/quality.md +43 -0
  42. package/harness/assets/domains/security.md +44 -0
  43. package/harness/assets/domains/support.md +45 -0
  44. package/harness/assets/epic-template.md +85 -0
  45. package/harness/assets/feature-template.md +86 -0
  46. package/harness/assets/handoff-template.md +26 -0
  47. package/harness/assets/task-template.md +38 -0
  48. package/harness/assets/version-template.md +43 -0
  49. package/harness/harness/__init__.py +7 -0
  50. package/harness/harness/align.py +582 -0
  51. package/harness/harness/architecture.py +254 -0
  52. package/harness/harness/autonomy.py +374 -0
  53. package/harness/harness/branches.py +408 -0
  54. package/harness/harness/config.py +1482 -0
  55. package/harness/harness/coverage.py +199 -0
  56. package/harness/harness/epic.py +220 -0
  57. package/harness/harness/events.py +153 -0
  58. package/harness/harness/extend.py +99 -0
  59. package/harness/harness/frontmatter.py +218 -0
  60. package/harness/harness/gate.py +591 -0
  61. package/harness/harness/generate.py +267 -0
  62. package/harness/harness/git.py +775 -0
  63. package/harness/harness/ids.py +140 -0
  64. package/harness/harness/kickoff.py +231 -0
  65. package/harness/harness/lint.py +505 -0
  66. package/harness/harness/model.py +364 -0
  67. package/harness/harness/peers.py +187 -0
  68. package/harness/harness/product.py +29 -0
  69. package/harness/harness/registry.py +382 -0
  70. package/harness/harness/report.py +227 -0
  71. package/harness/harness/safety.py +387 -0
  72. package/harness/harness/scaffold.py +129 -0
  73. package/harness/harness/shard.py +63 -0
  74. package/harness/harness/shift.py +348 -0
  75. package/harness/harness/task.py +507 -0
  76. package/harness/harness/tree.py +258 -0
  77. package/harness/harness/version.py +305 -0
  78. package/harness/harness/wrap.py +217 -0
  79. package/harness/hooks/guard.py +259 -0
  80. package/harness/presets/appchy/PRESET.md +717 -0
  81. package/harness/presets/appchy/references/artifacts.md +539 -0
  82. package/harness/presets/appchy/references/graph.md +154 -0
  83. package/harness/presets/appchy/references/operations.md +444 -0
  84. package/harness/presets/appchy/references/research.md +216 -0
  85. package/harness/schema/work.config.schema.json +401 -0
  86. package/harness/test_work.py +5002 -0
  87. package/harness/work.py +534 -0
  88. package/package.json +34 -40
  89. package/bin/config-change.dev.mjs +0 -17
  90. package/bin/jarvis-dev.mjs +0 -30
  91. package/bin/pre-tool-use.dev.mjs +0 -23
  92. package/bin/session-start.dev.mjs +0 -17
  93. package/bin/stop.dev.mjs +0 -17
  94. package/bin/user-prompt-submit.dev.mjs +0 -17
  95. package/dev/bin.js +0 -38821
  96. package/dev/bin.js.map +0 -1
  97. package/dev/env.json +0 -1
  98. package/dev/hooks/config-change.js +0 -110
  99. package/dev/hooks/config-change.js.map +0 -1
  100. package/dev/hooks/pre-tool-use.js +0 -120
  101. package/dev/hooks/pre-tool-use.js.map +0 -1
  102. package/dev/hooks/session-start.js +0 -115
  103. package/dev/hooks/session-start.js.map +0 -1
  104. package/dev/hooks/stop.js +0 -112
  105. package/dev/hooks/stop.js.map +0 -1
  106. package/dev/hooks/user-prompt-submit.js +0 -111
  107. package/dev/hooks/user-prompt-submit.js.map +0 -1
  108. package/dist/env.json +0 -1
@@ -0,0 +1,218 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ from .tree import TASK_FM_ORDER
5
+
6
+ #: Keys rendered as `- item` block lists rather than inline `[a, b]`.
7
+ #:
8
+ #: It is not a formatting preference: the inline form splits on commas, and every
9
+ #: key here holds free text a human wrote — a question, what was observed, a
10
+ #: verify command's name and result. `--question "does it ship signed, or later?"`
11
+ #: rendered inline would come back as two list entries, and the answer would be
12
+ #: filed against half a question.
13
+ BLOCK_LISTS = ("sessions", "plans", "asked", "verified", "observed")
14
+
15
+ #: A field this parser reads: a name it models, and whatever follows the colon.
16
+ _KEY = re.compile(r"^([A-Za-z0-9_]+):\s*(.*)$")
17
+
18
+ #: Where one field ends and the next begins — ANY `key:` at column zero, including
19
+ #: the ones `_KEY` declines. It is deliberately wider than what we read: a field we
20
+ #: cannot parse still has to be bounded, or the lines under it get attributed to
21
+ #: whatever came before. A leading `-` or `[` is excluded so a flush-left list item
22
+ #: carrying a colon ("- 2026-01-01 does it ship signed: or later?") stays part of
23
+ #: its list rather than starting a field of its own.
24
+ _FIELD = re.compile(r"^[^\s#\-\[][^:]*:")
25
+
26
+
27
+ def split_frontmatter(text: str):
28
+ """Return (frontmatter_text, body_text). frontmatter_text is None when the
29
+ file has no `---` fenced block at the top.
30
+
31
+ CRLF is matched as well as LF. A file written on Windows is ordinary YAML that
32
+ every other reader of this format handles; not matching it here meant Python saw
33
+ no frontmatter at all, and a rewrite then returned the file untouched — so a
34
+ `move` on such a task reported success and changed nothing."""
35
+ if not text.startswith("---"):
36
+ return None, text
37
+ m = re.match(r"^---\r?\n(.*?)\r?\n---\r?\n?", text, re.DOTALL)
38
+ if not m:
39
+ return None, text
40
+ return m.group(1), text[m.end():]
41
+ def _eol(text: str) -> str:
42
+ """The line ending the file already uses.
43
+
44
+ Reading CRLF correctly and writing LF back is still every line rewritten for
45
+ whoever produced it, so what comes out matches what went in."""
46
+ return "\r\n" if text.startswith("---\r\n") else "\n"
47
+ def _inline(raw: str) -> list:
48
+ """`[a, b]` as a list. Splits on every comma and ignores quoting — narrower than
49
+ YAML, and left that way on purpose: widening it changes how existing values read,
50
+ which is a separate question from whether a write destroys them."""
51
+ return [p.strip() for p in raw[1:-1].strip().split(",") if p.strip()]
52
+ def _bullets(value) -> list:
53
+ """A block list's item lines. One writer of the ` - ` shape, so the two places
54
+ that emit one cannot drift apart on the indent."""
55
+ return [f" - {item}" for item in as_list(value)]
56
+ #: A field's value when its shape is not one this parser reads. It is a value rather
57
+ #: than a missing key so the renderer still knows the field is THERE — which is what
58
+ #: stops a mutation writing a second copy of a key the file already has.
59
+ KEPT = object()
60
+
61
+
62
+ def _name(line: str) -> str:
63
+ """A field's key as written, whether or not this parser can read its value."""
64
+ return line.split(":", 1)[0].strip()
65
+ def _field(block: list):
66
+ """One field, as (name, value, source_lines).
67
+
68
+ `value` is `KEPT` when the field's shape is not one this parser reads — a dash in
69
+ the name, a nested map, an unterminated list. Those carry their source lines
70
+ instead, because the alternative to understanding a field is keeping it, never
71
+ dropping it."""
72
+ m = _KEY.match(block[0])
73
+ # Blank and comment lines are nobody's content. Counting them as a field's value
74
+ # made `owner: x` followed by a `# note` unreadable, which the plainer parser
75
+ # before this one got right.
76
+ rest = [line for line in block[1:]
77
+ if line.strip() and not line.lstrip().startswith("#")]
78
+ if not m:
79
+ return _name(block[0]), KEPT, block
80
+ key, raw = m.group(1), m.group(2).strip()
81
+
82
+ # The value as one line, wherever it started. A list prettier wrapped across
83
+ # lines is the same list — and it may open on the key's own line or on the next
84
+ # one, which is why the join happens before anything is decided rather than
85
+ # once per entry shape.
86
+ joined = " ".join(([raw] if raw else []) + [line.strip() for line in rest])
87
+
88
+ if joined.startswith("["):
89
+ return (key, _inline(joined), block) if joined.endswith("]") else (key, KEPT, block)
90
+ if raw:
91
+ # A scalar with lines under it is a shape we do not read — a folded block,
92
+ # most likely — so it is kept whole rather than half-understood.
93
+ return (key, raw, block) if not rest else (key, KEPT, block)
94
+ if not rest:
95
+ return key, "", block
96
+ if all(re.match(r"^\s*-\s+", line) for line in rest):
97
+ # Any indent, zero included. `sessions:` with its items at column zero is
98
+ # valid YAML that a person writes by hand, and demanding leading whitespace
99
+ # read it as empty — which then deleted it.
100
+ return key, [line.strip()[1:].strip() for line in rest], block
101
+ return key, KEPT, block
102
+ def _fields(fm: str):
103
+ """Walk the frontmatter one field at a time. Everything between a `key:` at
104
+ column zero and the next one belongs to that field."""
105
+ lines = fm.splitlines()
106
+ i = 0
107
+ while i < len(lines):
108
+ if not _FIELD.match(lines[i]):
109
+ i += 1
110
+ continue
111
+ start = i
112
+ i += 1
113
+ while i < len(lines) and not _FIELD.match(lines[i]):
114
+ i += 1
115
+ yield _field(lines[start:i])
116
+ def parse_frontmatter(text: str) -> dict:
117
+ """Parse the controlled subset of YAML used in frontmatter:
118
+ `key: scalar`, `key: []`, `key: [a, b]` (wrapped or not), and block lists of
119
+ `- item`. A field outside that subset is absent rather than empty — an empty
120
+ value would be indistinguishable from one somebody wrote."""
121
+ fm, _ = split_frontmatter(text)
122
+ if fm is None:
123
+ return {}
124
+ return {k: v for k, v, _ in _fields(fm) if v is not KEPT}
125
+ def render_frontmatter(data: dict, order=TASK_FM_ORDER, kept=None, eol="\n") -> str:
126
+ """Serialize a frontmatter dict back to a `---` fenced block. Known keys are
127
+ emitted in `order`; the `BLOCK_LISTS` keys are rendered as block lists. `kept`
128
+ maps a field the parser could not read to its source lines, put back as they
129
+ were found."""
130
+ kept = kept or {}
131
+ keys = [k for k in order if k in data]
132
+ keys += [k for k in data if k not in order]
133
+ lines = []
134
+ for k in keys:
135
+ # A field nobody could read is not a field nobody has. Rendering this one
136
+ # from `data` would write a SECOND `verified:` into the file — the mutation
137
+ # appended to a list it was told was empty — and the first would then be
138
+ # invisible to every later read while its bytes sat there looking fine.
139
+ if k in kept:
140
+ continue
141
+ v = data[k]
142
+ if k in BLOCK_LISTS:
143
+ # A scalar here is a person writing `plans: plan.md`, which is the same
144
+ # thing as a one-item list and used to be dropped for not being one.
145
+ if as_list(v):
146
+ lines.append(f"{k}:")
147
+ lines += _bullets(v)
148
+ continue
149
+ if isinstance(v, list):
150
+ lines.append(f"{k}: [{', '.join(v)}]")
151
+ else:
152
+ lines.append(f"{k}: {v}")
153
+ for name, block in kept.items():
154
+ lines += block
155
+ # Whatever the mutation added lands under the lines it could not read, so a
156
+ # session or a verification is still recorded against a field this parser
157
+ # will go on declining to understand.
158
+ if name in BLOCK_LISTS:
159
+ lines += _bullets(data.get(name))
160
+ return "---" + eol + eol.join(lines) + eol + "---" + eol
161
+ def rewrite_frontmatter(text: str, mutate, order=TASK_FM_ORDER) -> str:
162
+ """Apply `mutate(data)` to the parsed frontmatter and re-render the file."""
163
+ fm, body = split_frontmatter(text)
164
+ if fm is None:
165
+ return text
166
+ fields = list(_fields(fm))
167
+ data = {k: v for k, v, _ in fields if v is not KEPT}
168
+ kept = {k: block for k, v, block in fields if v is KEPT}
169
+ mutate(data)
170
+ return render_frontmatter(data, order, kept, _eol(text)) + body
171
+ def read_item(md: Path) -> str:
172
+ """A work file's text, with the line endings it actually has on disk.
173
+
174
+ `Path.read_text()` translates CRLF to LF on the way in, and `write_text` does not
175
+ put it back — so a file written on Windows came back LF, was written LF, and
176
+ every line of it changed on a command asked to set one field. Nothing was lost
177
+ that a reader would notice, which is what made it worth closing: a diff touching
178
+ the whole file is one nobody reviews."""
179
+ with open(md, "r", encoding="utf-8", newline="") as f:
180
+ return f.read()
181
+ def write_item(md: Path, text: str) -> None:
182
+ """The other half of `read_item`: write the endings the text already carries,
183
+ rather than the ones this platform prefers."""
184
+ with open(md, "w", encoding="utf-8", newline="") as f:
185
+ f.write(text)
186
+ def rewrite_file(md: Path, mutate, order=TASK_FM_ORDER) -> None:
187
+ """`rewrite_frontmatter` over a file in place. The one door onto a board file's
188
+ frontmatter, so what it guarantees is guaranteed once rather than at thirteen
189
+ call sites that each read and wrote for themselves."""
190
+ write_item(md, rewrite_frontmatter(read_item(md), mutate, order))
191
+ def unquote(value: str) -> str:
192
+ """A scalar with its surrounding quotes taken off.
193
+
194
+ `_inline` splits on commas and leaves quoting alone, deliberately — widening it
195
+ would change how every existing value reads. So a field authored in the quoted
196
+ JSON style (`paths: ["a", "b"]`, `retired: ["x=y"]`) arrives with the quote
197
+ characters part of the string, and every reader of one has to take them off. This
198
+ is that, once, beside the parser that hands them over.
199
+
200
+ Only a MATCHED pair: a lone quote is a character somebody typed, not quoting, and
201
+ stripping it would quietly rewrite the value.
202
+ """
203
+ text = str(value).strip()
204
+ for quote in ('"', "'"):
205
+ if len(text) >= 2 and text.startswith(quote) and text.endswith(quote):
206
+ return text[1:-1]
207
+ return text
208
+ def as_list(value) -> list:
209
+ if isinstance(value, list):
210
+ return value
211
+ return [value] if value else []
212
+ def title_of(md: Path) -> str:
213
+ """The title — the first `# ` heading in the body."""
214
+ body = split_frontmatter(md.read_text())[1]
215
+ for line in body.splitlines():
216
+ if line.startswith("# "):
217
+ return line[2:].strip()
218
+ return md.parent.name